@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,649 @@
1
+ // The shared contrast helper: colour parsing and conversion, WCAG 2.x
2
+ // luminance and ratio, the large-text and requirement rules, and the
3
+ // measurement of one text element and one document. Pages receive it as
4
+ // source text (Function.prototype.toString), so the factory references
5
+ // nothing outside itself except standard built-ins, and reaches the DOM only
6
+ // through the el / win / doc arguments it is given. It only reads the page.
7
+ export function contrastToolkit() {
8
+ // Chromium's computed-colour serializations, exactly as Chromium writes
9
+ // them, and nothing else: rgb(r, g, b) and rgba(r, g, b, a) with ", "
10
+ // separators; color() in eight predefined spaces, lab, lch, oklab and oklch
11
+ // with three single-space-separated components and an optional " / alpha".
12
+ // Numbers carry no "+", no bare "." and no unit or percent.
13
+ const NUMBER = "-?\\d+(?:\\.\\d+)?(?:e[+-]\\d+)?";
14
+ const MODERN_ARG = `(${NUMBER}|none)`;
15
+ const LEGACY_RGB = new RegExp(`^rgb(a?)\\((${NUMBER}), (${NUMBER}), (${NUMBER})(?:, (${NUMBER}))?\\)$`);
16
+ const MODERN = new RegExp(`^(?:color\\((srgb|srgb-linear|display-p3|a98-rgb|prophoto-rgb|rec2020|xyz-d50|xyz-d65) |(lab|lch|oklab|oklch)\\()${MODERN_ARG} ${MODERN_ARG} ${MODERN_ARG}(?: \\/ ${MODERN_ARG})?\\)$`);
17
+
18
+ // CSS Color 4 conversion matrices and transfer functions.
19
+ const XYZ_D65_TO_LINEAR_SRGB = [
20
+ [12831 / 3959, -329 / 214, -1974 / 3959],
21
+ [-851781 / 878810, 1648619 / 878810, 36519 / 878810],
22
+ [705 / 12673, -2585 / 12673, 705 / 667],
23
+ ];
24
+ const LINEAR_P3_TO_XYZ_D65 = [
25
+ [608311 / 1250200, 189793 / 714400, 198249 / 1000160],
26
+ [35783 / 156275, 247089 / 357200, 198249 / 2500400],
27
+ [0, 32229 / 714400, 5220557 / 5000800],
28
+ ];
29
+ const LINEAR_A98_TO_XYZ_D65 = [
30
+ [573536 / 994567, 263643 / 1420810, 187206 / 994567],
31
+ [591459 / 1989134, 6239551 / 9945670, 374412 / 4972835],
32
+ [53769 / 1989134, 351524 / 4972835, 4929758 / 4972835],
33
+ ];
34
+ const LINEAR_PROPHOTO_TO_XYZ_D50 = [
35
+ [0.7977666449006423, 0.1351812974005331, 0.0313477341283922],
36
+ [0.2880748288194013, 0.7118352342418731, 0.0000899369387256],
37
+ [0, 0, 0.8251046025104602],
38
+ ];
39
+ const LINEAR_REC2020_TO_XYZ_D65 = [
40
+ [63426534 / 99577255, 20160776 / 139408157, 47086771 / 278816314],
41
+ [26158966 / 99577255, 472592308 / 697040785, 8267143 / 139408157],
42
+ [0, 19567812 / 697040785, 295819943 / 278816314],
43
+ ];
44
+ const XYZ_D50_TO_D65 = [
45
+ [0.955473421488075, -0.02309845494876471, 0.06325924320057072],
46
+ [-0.0283697093338637, 1.0099953980813041, 0.021041441191917323],
47
+ [0.012314014864481998, -0.020507649298898964, 1.330365926242124],
48
+ ];
49
+ const OKLAB_TO_LMS = [
50
+ [1, 0.3963377773761749, 0.2158037573099136],
51
+ [1, -0.1055613458156586, -0.0638541728258133],
52
+ [1, -0.0894841775298119, -1.2914855480194092],
53
+ ];
54
+ const LMS_TO_XYZ_D65 = [
55
+ [1.2268798758459243, -0.5578149944602171, 0.2813910456659647],
56
+ [-0.0405757452148008, 1.112286803280317, -0.0717110580655164],
57
+ [-0.0763729366746601, -0.4214933324022432, 1.5869240198367816],
58
+ ];
59
+ const D50_WHITE = [0.3457 / 0.3585, 1, (1 - 0.3457 - 0.3585) / 0.3585];
60
+ const REC2020_ALPHA = 1.09929682680944;
61
+ const REC2020_BETA = 0.018053968510807;
62
+
63
+ // Math.pow, **, exp, log, sin and cos are implementation-approximated and
64
+ // differ in the last bits between Chromium and Node, so a page measurement
65
+ // would not re-derive exactly elsewhere. Every value here comes from + - * /
66
+ // alone (exactly rounded in every engine), with fixed-length series.
67
+ const LN2 = 0.6931471805599453;
68
+ const ln = (x) => {
69
+ let exponent = 0;
70
+ let m = x;
71
+ while (m > Math.SQRT2) { m /= 2; exponent += 1; }
72
+ while (m < Math.SQRT1_2) { m *= 2; exponent -= 1; }
73
+ const s = (m - 1) / (m + 1);
74
+ let term = s;
75
+ let sum = 0;
76
+ for (let k = 1; k <= 41; k += 2) { sum += term / k; term *= s * s; }
77
+ return 2 * sum + exponent * LN2;
78
+ };
79
+ const exp = (y) => {
80
+ const k = Math.round(y / LN2);
81
+ const r = y - k * LN2;
82
+ let term = 1;
83
+ let sum = 1;
84
+ for (let n = 1; n <= 20; n += 1) { term *= r / n; sum += term; }
85
+ for (let i = 0; i < Math.abs(k); i += 1) sum = k < 0 ? sum / 2 : sum * 2;
86
+ return sum;
87
+ };
88
+ const pow = (x, y) => (x === 0 || !Number.isFinite(x) ? x : exp(y * ln(x)));
89
+ const cube = (x) => x * x * x;
90
+ // [cos, sin] of a hue in degrees.
91
+ const cosSin = (degrees) => {
92
+ const turn = ((degrees % 360) + 360) % 360;
93
+ const quadrant = Math.round(turn / 90);
94
+ const x = (turn - quadrant * 90) * (Math.PI / 180);
95
+ let c = 1;
96
+ let s = x;
97
+ let tc = 1;
98
+ let ts = x;
99
+ for (let n = 1; n <= 10; n += 1) {
100
+ tc *= -(x * x) / ((2 * n - 1) * (2 * n));
101
+ ts *= -(x * x) / ((2 * n) * (2 * n + 1));
102
+ c += tc;
103
+ s += ts;
104
+ }
105
+ return [[c, s], [-s, c], [-c, -s], [s, -c], [c, s]][quadrant];
106
+ };
107
+
108
+ const multiply = (matrix, vector) => matrix.map((row) => row[0] * vector[0] + row[1] * vector[1] + row[2] * vector[2]);
109
+ const signed = (c, magnitude) => (c < 0 ? -magnitude : magnitude);
110
+ const srgbToLinear = (c) => (Math.abs(c) <= 0.04045 ? c / 12.92 : signed(c, pow((Math.abs(c) + 0.055) / 1.055, 2.4)));
111
+ const linearToSrgb = (c) => (Math.abs(c) > 0.0031308 ? signed(c, 1.055 * pow(Math.abs(c), 1 / 2.4) - 0.055) : 12.92 * c);
112
+ const polarToCartesian = ([l, chroma, hue]) => {
113
+ const [cos, sin] = cosSin(hue);
114
+ return [l, chroma * cos, chroma * sin];
115
+ };
116
+ const labToXyzD50 = ([l, a, b]) => {
117
+ const kappa = 24389 / 27;
118
+ const epsilon = 216 / 24389;
119
+ const fy = (l + 16) / 116;
120
+ const fx = a / 500 + fy;
121
+ const fz = fy - b / 200;
122
+ const xyz = [cube(fx) > epsilon ? cube(fx) : (116 * fx - 16) / kappa, l > kappa * epsilon ? cube(fy) : l / kappa, cube(fz) > epsilon ? cube(fz) : (116 * fz - 16) / kappa];
123
+ return xyz.map((value, i) => value * D50_WHITE[i]);
124
+ };
125
+ const TO_XYZ_D65 = {
126
+ "display-p3": (c) => multiply(LINEAR_P3_TO_XYZ_D65, c.map(srgbToLinear)),
127
+ "a98-rgb": (c) => multiply(LINEAR_A98_TO_XYZ_D65, c.map((v) => signed(v, pow(Math.abs(v), 563 / 256)))),
128
+ "prophoto-rgb": (c) => multiply(XYZ_D50_TO_D65, multiply(LINEAR_PROPHOTO_TO_XYZ_D50, c.map((v) => (Math.abs(v) <= 16 / 512 ? v / 16 : signed(v, pow(Math.abs(v), 1.8)))))),
129
+ rec2020: (c) => multiply(LINEAR_REC2020_TO_XYZ_D65, c.map((v) => (Math.abs(v) < REC2020_BETA * 4.5 ? v / 4.5 : signed(v, pow((Math.abs(v) + REC2020_ALPHA - 1) / REC2020_ALPHA, 1 / 0.45))))),
130
+ "xyz-d50": (c) => multiply(XYZ_D50_TO_D65, c),
131
+ "xyz-d65": (c) => c,
132
+ lab: (c) => multiply(XYZ_D50_TO_D65, labToXyzD50(c)),
133
+ lch: (c) => multiply(XYZ_D50_TO_D65, labToXyzD50(polarToCartesian(c))),
134
+ oklab: (c) => multiply(LMS_TO_XYZ_D65, multiply(OKLAB_TO_LMS, c).map(cube)),
135
+ oklch: (c) => multiply(LMS_TO_XYZ_D65, multiply(OKLAB_TO_LMS, polarToCartesian(c)).map(cube)),
136
+ };
137
+
138
+ function parseComputedColor(str) {
139
+ const text = typeof str === "string" ? str : "";
140
+ const component = (part) => (part === undefined ? 1 : part === "none" ? 0 : Number(part));
141
+ // rgba() carries an alpha and rgb() does not.
142
+ const legacy = LEGACY_RGB.exec(text);
143
+ if (legacy && (legacy[1] === "a") === (legacy[5] !== undefined)) return { space: "rgb", coords: [Number(legacy[2]), Number(legacy[3]), Number(legacy[4])], alpha: component(legacy[5]) };
144
+ const modern = MODERN.exec(text);
145
+ if (modern) return { space: modern[1] || modern[2], coords: [component(modern[3]), component(modern[4]), component(modern[5])], alpha: component(modern[6]) };
146
+ return { unparseable: str };
147
+ }
148
+
149
+ function toSrgb(color) {
150
+ const { space, coords, alpha } = color;
151
+ const encoded = space === "rgb" ? coords.map((c) => c / 255)
152
+ : space === "srgb" ? coords
153
+ : space === "srgb-linear" ? coords.map(linearToSrgb)
154
+ : multiply(XYZ_D65_TO_LINEAR_SRGB, TO_XYZ_D65[space](coords)).map(linearToSrgb);
155
+ const rgb = encoded.map((c) => Math.min(1, Math.max(0, c)));
156
+ return { rgb, alpha, gamut_clipped: rgb.some((c, i) => c !== encoded[i]) };
157
+ }
158
+
159
+ function relativeLuminance([r, g, b]) {
160
+ return 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b);
161
+ }
162
+
163
+ function contrastRatio(La, Lb) {
164
+ return (Math.max(La, Lb) + 0.05) / (Math.min(La, Lb) + 0.05);
165
+ }
166
+
167
+ // Source-over in gamma-encoded sRGB, as Chromium paints.
168
+ function compositeOver(top, bottom) {
169
+ const alpha = top.alpha + bottom.alpha * (1 - top.alpha);
170
+ if (alpha === 0) return { rgb: [0, 0, 0], alpha: 0 };
171
+ return { rgb: top.rgb.map((c, i) => (c * top.alpha + bottom.rgb[i] * bottom.alpha * (1 - top.alpha)) / alpha), alpha };
172
+ }
173
+
174
+ function isLargeText({ fontSizePx, fontWeight }) {
175
+ return fontSizePx >= 24 || (fontSizePx >= 18.66 && fontWeight >= 700);
176
+ }
177
+
178
+ function requiredRatio(isLarge) {
179
+ return isLarge ? 3 : 4.5;
180
+ }
181
+
182
+ function meetsRequirement(ratio, required) {
183
+ return ratio >= required;
184
+ }
185
+
186
+ function displayRatio(ratio) {
187
+ return Math.round(ratio * 100) / 100;
188
+ }
189
+
190
+ // bg_layers_raw runs from the element outwards and ends at the opaque
191
+ // layer (or the default canvas). Without an opaque bottom there is no
192
+ // painted background to compare against, so the colour fields are null.
193
+ function deriveElementMeasurement({ fg_raw, fill_raw, bg_layers_raw, font_size_px, font_weight }) {
194
+ const large = isLargeText({ fontSizePx: font_size_px, fontWeight: font_weight });
195
+ const sized = { size_class: large ? "large" : "normal", required: requiredRatio(large) };
196
+ const color = parseComputedColor(fg_raw);
197
+ const fill = parseComputedColor(fill_raw);
198
+ const layers = (Array.isArray(bg_layers_raw) ? bg_layers_raw : []).map(parseComputedColor);
199
+ if ([color, fill, ...layers].some((parsed) => "unparseable" in parsed)) {
200
+ return { fg_srgb: null, bg_srgb: null, gamut_clipped: null, ratio: null, ...sized, review_reason: "unparseable_color" };
201
+ }
202
+ const foreground = toSrgb(JSON.stringify(fill) !== JSON.stringify(color) ? fill : color);
203
+ const backgrounds = layers.map(toSrgb);
204
+ const gamut_clipped = [foreground, ...backgrounds].some((converted) => converted.gamut_clipped);
205
+ const background = backgrounds.reduceRight((under, layer) => compositeOver(layer, under), { rgb: [0, 0, 0], alpha: 0 });
206
+ if (background.alpha < 1) return { fg_srgb: null, bg_srgb: null, gamut_clipped, ratio: null, ...sized, review_reason: null };
207
+ const fg_srgb = compositeOver(foreground, background).rgb;
208
+ const ratio = contrastRatio(relativeLuminance(fg_srgb), relativeLuminance(background.rgb));
209
+ return { fg_srgb, bg_srgb: background.rgb, gamut_clipped, ratio, ...sized, review_reason: null };
210
+ }
211
+
212
+ // Element measurement. Review reasons are indexed by trigger number; the
213
+ // lowest-numbered trigger that holds is the element's review_reason.
214
+ const REVIEW_REASONS = [null, "background_gradient", "background_image", "pseudo_element_background", "overlapping_layer", "opacity", "filter", "blend_mode", "text_fill_background", "unparseable_color", "canvas_unknown", "disabled_state_uncertain", "mask"];
215
+ const CONTROL_SELECTOR = "button, input, select, textarea, a[href], [role=\"button\"], [role=\"checkbox\"], [role=\"radio\"], [role=\"switch\"], [data-next-action], [data-next-upsell-action]";
216
+ const DISABLED_SELECTOR = ":disabled, [aria-disabled=\"true\"]";
217
+ const LOADING_ATTRIBUTES = ["data-next-loading", "data-next-sdk-loading"];
218
+ const STATE_SELECTORS = [
219
+ ["selected", "[data-next-selected=\"true\"], .next-selected, [aria-checked=\"true\"]"],
220
+ ["active", ".next-active, .next-in-cart, [aria-pressed=\"true\"]"],
221
+ ];
222
+ const ROLE_SELECTOR_ATTRIBUTES = [
223
+ "data-next-action", "data-next-upsell-action", "os-checkout-payment", "type",
224
+ "data-next-bundle-card", "data-next-selector-card", "data-next-package-id", "data-next-bundle-id",
225
+ "data-next-toggle-card", "data-next-bump", "data-next-package-toggle",
226
+ "data-next-display", "data-next-bundle-display", "data-next-checkout-field",
227
+ ];
228
+ const REPLACED_ELEMENTS = new Set(["img", "video", "canvas", "svg", "picture", "iframe", "object", "embed"]);
229
+ const SVG_NS = "http://www.w3.org/2000/svg";
230
+ // What an SVG drawing paints as layers of its own: shapes, images, <use>,
231
+ // foreign objects and nested drawings.
232
+ const SVG_LAYERS = new Set(["rect", "circle", "ellipse", "line", "polyline", "polygon", "path", "image", "use", "foreignObject", "svg"]);
233
+ const SDK_LOADER_SRC = /campaign-cart(?:@[^"']*)?\/dist\/loader\.js/i;
234
+ const DEFAULT_CANVAS = "rgb(255, 255, 255)";
235
+ const COLOUR_FIELDS = { fg_raw: null, fill_raw: null, bg_layers_raw: null, fg_srgb: null, bg_srgb: null, gamut_clipped: null, ratio: null };
236
+
237
+ const isSet = (value, none = "none") => value !== "" && value !== none;
238
+ const transparent = (value) => parseComputedColor(value).alpha === 0;
239
+ const paintsBackground = (style) => isSet(style.getPropertyValue("background-image")) || !transparent(style.getPropertyValue("background-color"));
240
+ const intersects = (a, b) => a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
241
+ const isVisible = (el) => {
242
+ const rect = el.getBoundingClientRect();
243
+ return el.checkVisibility({ visibilityProperty: true }) && rect.width > 0 && rect.height > 0;
244
+ };
245
+
246
+ // The flat tree, as the page renders it: an element slotted into an open
247
+ // shadow root sits in its slot, and the top element of a shadow tree sits
248
+ // in the root's host. Closed roots hide their slots, so an element slotted
249
+ // into one steps to its light-DOM parent.
250
+ const shadowRootOf = (el) => (el.parentNode && el.parentNode.nodeType === 11 && el.parentNode.host ? el.parentNode : null);
251
+ const flatParent = (el) => el.assignedSlot || el.parentElement || (shadowRootOf(el) ? shadowRootOf(el).host : null);
252
+ const flatClosest = (el, selector) => {
253
+ for (let at = el; at && at.nodeType === 1; at = flatParent(at)) if (at.matches(selector)) return at;
254
+ return null;
255
+ };
256
+ const flatContains = (ancestor, el) => {
257
+ for (let at = el; at; at = flatParent(at)) if (at === ancestor) return true;
258
+ return false;
259
+ };
260
+ // Every element at or under `node` (an element, a document or a shadow
261
+ // root), each open shadow root inside it included: the node's own tree in
262
+ // document order, then each shadow tree in the order its host was reached.
263
+ const flatSubtree = (node) => {
264
+ const found = node.nodeType === 1 ? [node, ...node.querySelectorAll("*")] : Array.from(node.querySelectorAll("*"));
265
+ for (let at = 0; at < found.length; at += 1) {
266
+ if (found[at].shadowRoot) found.push(...found[at].shadowRoot.querySelectorAll("*"));
267
+ }
268
+ return found;
269
+ };
270
+ // Inside an SVG drawing, CSS backgrounds are painted only by the outermost
271
+ // <svg> and by a foreignObject; its other elements paint with fill and
272
+ // stroke.
273
+ const isSvg = (el) => el.namespaceURI === SVG_NS;
274
+ const paintsCssBackground = (el) => !isSvg(el) || el.localName === "foreignObject" || (el.localName === "svg" && !(flatParent(el) && isSvg(flatParent(el))));
275
+ // The outermost <svg> of the drawing an element sits in, or null.
276
+ const drawingOf = (el) => {
277
+ let at = el;
278
+ while (at && !isSvg(at)) at = flatParent(at);
279
+ let drawing = null;
280
+ for (; at && isSvg(at); at = flatParent(at)) if (at.localName === "svg") drawing = at;
281
+ return drawing;
282
+ };
283
+ // The frame element a window is shown in, when the document around it is
284
+ // readable (same-origin); otherwise null.
285
+ const frameOf = (view) => {
286
+ try {
287
+ return (view && view.frameElement) || null;
288
+ } catch {
289
+ return null;
290
+ }
291
+ };
292
+ // The next element out from `at` as the page paints it, with that
293
+ // element's window: its flat-tree parent, or, from the root of a
294
+ // same-origin frame's document, the frame element in the document around
295
+ // it.
296
+ const outward = (at, view) => {
297
+ const up = flatParent(at);
298
+ if (up) return [up, view];
299
+ const frame = at.ownerDocument && at === at.ownerDocument.documentElement ? frameOf(view) : null;
300
+ return frame ? [frame, frame.ownerDocument.defaultView] : [null, view];
301
+ };
302
+ // Where an element's own text renders: the flat-tree parents of its
303
+ // non-blank text nodes. That is the element itself, except for the host of
304
+ // an open shadow root, whose own text renders only through the slot that
305
+ // takes it in. Such text inherits that slot's style and is painted by the
306
+ // shadow tree around the slot; text no slot takes in, or that its slot
307
+ // does not show, is not rendered.
308
+ const textParents = (el, win) => {
309
+ const own = Array.from(el.childNodes).filter((node) => node.nodeType === 3 && /\S/.test(node.nodeValue));
310
+ if (!el.shadowRoot) return own.length ? [el] : [];
311
+ const range = el.ownerDocument.createRange();
312
+ const shown = own.filter((node) => {
313
+ if (!node.assignedSlot || win.getComputedStyle(node.assignedSlot).getPropertyValue("visibility") !== "visible") return false;
314
+ range.selectNodeContents(node);
315
+ return range.getClientRects().length > 0;
316
+ });
317
+ return [...new Set(shown.map((node) => node.assignedSlot))];
318
+ };
319
+
320
+ // Overlap candidates are read once per document, open shadow roots
321
+ // included.
322
+ const overlapCandidates = new WeakMap();
323
+ function overlapCandidatesOf(doc, win) {
324
+ if (!overlapCandidates.has(doc)) {
325
+ const body = doc.body;
326
+ const elements = body ? flatSubtree(body).slice(1) : [];
327
+ const candidates = elements.filter((node) => {
328
+ if (!isVisible(node)) return false;
329
+ if (REPLACED_ELEMENTS.has(node.localName)) return true;
330
+ const style = win.getComputedStyle(node);
331
+ return ["absolute", "fixed", "sticky"].includes(style.getPropertyValue("position")) && paintsBackground(style);
332
+ });
333
+ overlapCandidates.set(doc, candidates.map((node) => ({ node, rect: node.getBoundingClientRect() })));
334
+ }
335
+ return overlapCandidates.get(doc);
336
+ }
337
+
338
+ // The element's place in the page: up to 8 flat-tree steps, outwards
339
+ // through each slot, each shadow host and each same-origin frame. ">>>"
340
+ // marks a step into another tree: a slot to the element slotted into it, a
341
+ // host to the top of its shadow tree, a frame to its document. The
342
+ // :nth-of-type index counts the element's siblings in its own tree.
343
+ function selectorPath(el) {
344
+ let path = "";
345
+ let joiner = ">";
346
+ let steps = 0;
347
+ for (let at = el; at && at.nodeType === 1 && steps < 8; steps += 1) {
348
+ const tag = at.localName;
349
+ const attributes = ROLE_SELECTOR_ATTRIBUTES.filter((name) => at.hasAttribute(name)).map((name) => `[${name}=${JSON.stringify(at.getAttribute(name))}]`).join("");
350
+ const siblings = at.parentElement || shadowRootOf(at);
351
+ const nth = siblings && tag !== "body" ? `:nth-of-type(${Array.from(siblings.children).filter((sibling) => sibling.localName === tag).indexOf(at) + 1})` : "";
352
+ path = `${tag}${attributes}${nth}${path ? `${joiner}${path}` : ""}`;
353
+ const up = flatParent(at);
354
+ const view = at.ownerDocument && at === at.ownerDocument.documentElement ? at.ownerDocument.defaultView : null;
355
+ joiner = up && up === at.parentElement ? ">" : ">>>";
356
+ at = up || (view && view.frameElement) || null;
357
+ }
358
+ return path;
359
+ }
360
+
361
+ // Expanded: inside the content an aria-expanded="true" control names
362
+ // through aria-controls, resolved in the control's own document or shadow
363
+ // root.
364
+ function stateOf(el) {
365
+ for (const [state, selector] of STATE_SELECTORS) if (flatClosest(el, selector)) return state;
366
+ if (flatClosest(el, "details[open]")) return "expanded";
367
+ for (let at = el; at; at = flatParent(at)) {
368
+ const root = at.id ? at.getRootNode() : null;
369
+ if (!root || root.getElementById(at.id) !== at) continue;
370
+ if (Array.from(root.querySelectorAll("[aria-expanded=\"true\"][aria-controls]")).some((control) => control.getAttribute("aria-controls").split(/\s+/).includes(at.id))) return "expanded";
371
+ }
372
+ return "default";
373
+ }
374
+
375
+ function isTextBearing(el, win) {
376
+ if (!isVisible(el)) return false;
377
+ if (el.localName === "input") return (el.type === "submit" || el.type === "button") && el.value !== "";
378
+ return textParents(el, win).length > 0;
379
+ }
380
+
381
+ // Whether an element's ::first-line or ::first-letter changes the colour,
382
+ // text fill or opacity of its text, or paints a background of its own.
383
+ // Read once per element.
384
+ const firstParts = new WeakMap();
385
+ const restylesFirstPart = (at, view, own) => {
386
+ if (!firstParts.has(at)) {
387
+ firstParts.set(at, ["::first-line", "::first-letter"].some((pseudo) => {
388
+ const part = view.getComputedStyle(at, pseudo);
389
+ const differs = (name) => part.getPropertyValue(name) !== own.getPropertyValue(name);
390
+ return differs("color") || differs("-webkit-text-fill-color")
391
+ || (paintsBackground(part) && (differs("background-color") || differs("background-image")))
392
+ || (Number.parseFloat(part.getPropertyValue("opacity")) < 1 && differs("opacity"));
393
+ }));
394
+ }
395
+ return firstParts.get(at);
396
+ };
397
+
398
+ // A drawing's visible layers (SVG_LAYERS), read once per drawing.
399
+ const drawingLayers = new WeakMap();
400
+ const drawingLayersOf = (drawing) => {
401
+ if (!drawingLayers.has(drawing)) {
402
+ const layers = flatSubtree(drawing).slice(1).filter((node) => isSvg(node) && SVG_LAYERS.has(node.localName) && isVisible(node));
403
+ drawingLayers.set(drawing, layers.map((node) => ({ node, rect: node.getBoundingClientRect() })));
404
+ }
405
+ return drawingLayers.get(drawing);
406
+ };
407
+
408
+ // The text's style, its control and state, and the paint walk start where
409
+ // its text renders (textParents). The element's identity, its rendered
410
+ // flag and its box are its own. A shadow host's text can render through
411
+ // more than one slot (manual slot assignment): each slot's text is read
412
+ // through that slot, and the first slot's reading is recorded. When another
413
+ // slot's text sits in another control or state, or reads other colours,
414
+ // font or backgrounds, the host's box holds text painted by a subtree
415
+ // outside the recorded slot's ancestry: review trigger 4, never one slot's
416
+ // measurement standing for all of it. Every slot's triggers count.
417
+ //
418
+ // Text in a same-origin frame's document is painted by the frame element
419
+ // and everything around it too: the paint walk continues from the
420
+ // document's root to the frame element in the document around it, paint
421
+ // effects up to the top root, backgrounds through a transparent frame
422
+ // document until an opaque layer, and overlap candidates in each
423
+ // document the text box is shown in. A frame document whose root
424
+ // color-scheme differs from its frame element's paints its own canvas,
425
+ // which is not read: trigger 10.
426
+ //
427
+ // SVG text is painted with its fill, not with CSS color: a fill that is a
428
+ // plain colour is the text fill (fill_raw); a fill that is not (a gradient
429
+ // or pattern, none, a context paint), a stroke or a paint order is trigger
430
+ // 9, and a fill opacity below 1 is trigger 5. A shape, image, foreign
431
+ // object or nested drawing of the same drawing whose box meets the text's
432
+ // is trigger 4.
433
+ //
434
+ // ::first-line and ::first-letter paint the first line or letter with
435
+ // their own style, inherited by the text of descendants on that line: on
436
+ // the text or on any element around it in its document, one whose colour,
437
+ // text fill or opacity differs from its element's, or that paints a
438
+ // background of its own, is trigger 3.
439
+ //
440
+ // A placeholder (measurePlaceholder) is read with its ::placeholder
441
+ // colour, text fill, font size and weight, over the field's backgrounds.
442
+ // A ::placeholder that paints a background of its own is trigger 3, and
443
+ // its opacity, filter, blend mode and mask count like the field's.
444
+ function measureText(el, win, placeholder) {
445
+ const origins = placeholder ? [el] : textParents(el, win);
446
+ const [origin = el] = origins;
447
+ const textStyleOf = (at) => (placeholder ? win.getComputedStyle(at, "::placeholder") : win.getComputedStyle(at));
448
+ const style = textStyleOf(origin);
449
+ const font_size_px = Number.parseFloat(style.getPropertyValue("font-size"));
450
+ const font_weight = Number(style.getPropertyValue("font-weight"));
451
+ const large = isLargeText({ fontSizePx: font_size_px, fontWeight: font_weight });
452
+ const control = flatClosest(origin, CONTROL_SELECTOR);
453
+ const state = stateOf(origin);
454
+ const mixed = origins.slice(1).some((other) => flatClosest(other, CONTROL_SELECTOR) !== control || stateOf(other) !== state);
455
+ const disabled = el.matches(DISABLED_SELECTOR) || (!mixed && Boolean(control && control.matches(DISABLED_SELECTOR)));
456
+ const control_loading = !mixed && Boolean(control) && LOADING_ATTRIBUTES.some((name) => control.hasAttribute(name) && control.getAttribute(name) !== "false");
457
+ const rendered = placeholder ? isVisible(el) && el.value === "" && /\S/.test(el.placeholder || "") : isTextBearing(el, win);
458
+ const head = { selector_path: selectorPath(el), state, disabled, rendered, font_size_px, font_weight, size_class: large ? "large" : "normal" };
459
+ if (disabled || control_loading) return { ...head, ...COLOUR_FIELDS, required: requiredRatio(large), review_reason: null, control_loading };
460
+
461
+ const triggers = new Set();
462
+ const paintEffects = (layer) => {
463
+ if (Number.parseFloat(layer.getPropertyValue("opacity")) < 1) triggers.add(5);
464
+ if (isSet(layer.getPropertyValue("filter")) || isSet(layer.getPropertyValue("backdrop-filter"))) triggers.add(6);
465
+ if (isSet(layer.getPropertyValue("mix-blend-mode"), "normal")) triggers.add(7);
466
+ if (isSet(layer.getPropertyValue("mask-image")) || isSet(layer.getPropertyValue("-webkit-mask-image"))) triggers.add(12);
467
+ };
468
+ const schemeOf = (view, at) => view.getComputedStyle(at).getPropertyValue("color-scheme");
469
+ // A scheme list that includes light is used as light (the page is read
470
+ // with a light colour-scheme preference); normal is light too.
471
+ const light = (scheme) => scheme === "normal" || scheme.trim().split(/\s+/).includes("light");
472
+ const sameScheme = (a, b) => (light(a) ? light(b) : a === b);
473
+ // One slot's (or the element's own) reading, adding the triggers on its
474
+ // way out.
475
+ const readThrough = (start) => {
476
+ const text = start === origin ? style : textStyleOf(start);
477
+ const fg_raw = text.getPropertyValue("color");
478
+ let fill_raw = text.getPropertyValue("-webkit-text-fill-color");
479
+ if (isSvg(start)) {
480
+ const fill = text.getPropertyValue("fill");
481
+ if ("unparseable" in parseComputedColor(fill)) triggers.add(9);
482
+ else fill_raw = fill;
483
+ if (isSet(text.getPropertyValue("stroke")) || isSet(text.getPropertyValue("paint-order"), "normal")) triggers.add(9);
484
+ if (Number.parseFloat(text.getPropertyValue("fill-opacity")) < 1) triggers.add(5);
485
+ }
486
+ if (placeholder) {
487
+ paintEffects(text);
488
+ if (paintsBackground(text)) triggers.add(3);
489
+ }
490
+ const fillTransparent = transparent(fill_raw);
491
+ const bg_layers_raw = [];
492
+ let opaque = false;
493
+ let unread = false;
494
+ let view = win;
495
+ let root = el.ownerDocument.documentElement;
496
+ for (let at = start; at && at.nodeType === 1;) {
497
+ const layer = at === start && !placeholder ? text : view.getComputedStyle(at);
498
+ paintEffects(layer);
499
+ if (view === win && !isSvg(at) && restylesFirstPart(at, view, layer)) triggers.add(3);
500
+ if (!opaque && !unread && paintsCssBackground(at)) {
501
+ const image = layer.getPropertyValue("background-image");
502
+ if (image.includes("-gradient(")) triggers.add(1);
503
+ else if (isSet(image)) triggers.add(2);
504
+ if (["::before", "::after"].some((pseudo) => {
505
+ const generated = view.getComputedStyle(at, pseudo);
506
+ return !["none", "normal"].includes(generated.getPropertyValue("content")) && paintsBackground(generated);
507
+ })) triggers.add(3);
508
+ if (fillTransparent && [layer.getPropertyValue("background-clip"), layer.getPropertyValue("-webkit-background-clip")].some((clip) => /\btext\b/.test(clip))) triggers.add(8);
509
+ const color = layer.getPropertyValue("background-color");
510
+ bg_layers_raw.push(color);
511
+ opaque = parseComputedColor(color).alpha >= 1;
512
+ }
513
+ const [next, nextView] = outward(at, view);
514
+ if (next && nextView !== view) {
515
+ if (!opaque && !unread && !sameScheme(schemeOf(view, root), schemeOf(nextView, next))) {
516
+ triggers.add(10);
517
+ unread = true;
518
+ }
519
+ root = next.ownerDocument.documentElement;
520
+ }
521
+ at = next;
522
+ view = nextView;
523
+ }
524
+ if (!opaque && !unread) {
525
+ if (light(schemeOf(view, root))) bg_layers_raw.push(DEFAULT_CANVAS);
526
+ else triggers.add(10);
527
+ }
528
+ return { fg_raw, fill_raw, bg_layers_raw, font_size_px: Number.parseFloat(text.getPropertyValue("font-size")), font_weight: Number(text.getPropertyValue("font-weight")) };
529
+ };
530
+ const { fg_raw, fill_raw, bg_layers_raw } = readThrough(origin);
531
+ const reading = JSON.stringify({ fg_raw, fill_raw, bg_layers_raw, font_size_px, font_weight });
532
+ if (mixed) triggers.add(4);
533
+ for (const other of origins.slice(1)) if (JSON.stringify(readThrough(other)) !== reading) triggers.add(4);
534
+
535
+ // Overlap in the element's own document, then in each document around
536
+ // its frames, with the text box moved into that document's viewport.
537
+ let box = el.getBoundingClientRect();
538
+ let inner = origin;
539
+ let view = win;
540
+ for (let doc = el.ownerDocument; doc;) {
541
+ if (overlapCandidatesOf(doc, view).some(({ node, rect }) => intersects(rect, box) && !flatContains(node, inner) && !flatContains(inner, node))) triggers.add(4);
542
+ const frame = frameOf(view);
543
+ if (!frame) break;
544
+ const outer = frame.ownerDocument.defaultView;
545
+ const frameBox = frame.getBoundingClientRect();
546
+ const frameStyle = outer.getComputedStyle(frame);
547
+ const dx = frameBox.left + frame.clientLeft + (Number.parseFloat(frameStyle.getPropertyValue("padding-left")) || 0);
548
+ const dy = frameBox.top + frame.clientTop + (Number.parseFloat(frameStyle.getPropertyValue("padding-top")) || 0);
549
+ box = { left: box.left + dx, right: box.right + dx, top: box.top + dy, bottom: box.bottom + dy };
550
+ inner = frame;
551
+ view = outer;
552
+ doc = frame.ownerDocument;
553
+ }
554
+ const drawing = drawingOf(origin);
555
+ if (drawing) {
556
+ const textBox = el.getBoundingClientRect();
557
+ if (drawingLayersOf(drawing).some(({ node, rect }) => intersects(rect, textBox) && !flatContains(node, origin) && !flatContains(origin, node))) triggers.add(4);
558
+ }
559
+ if (el.matches(".next-disabled") || Boolean(control && control.matches(".next-disabled"))) triggers.add(11);
560
+
561
+ const derived = deriveElementMeasurement({ fg_raw, fill_raw, bg_layers_raw, font_size_px, font_weight });
562
+ if (derived.review_reason) triggers.add(9);
563
+ return {
564
+ ...head,
565
+ fg_raw,
566
+ fill_raw,
567
+ bg_layers_raw,
568
+ fg_srgb: derived.fg_srgb,
569
+ bg_srgb: derived.bg_srgb,
570
+ gamut_clipped: derived.gamut_clipped,
571
+ ratio: derived.ratio,
572
+ required: derived.required,
573
+ review_reason: triggers.size ? REVIEW_REASONS[Math.min(...triggers)] : null,
574
+ control_loading,
575
+ };
576
+ }
577
+
578
+ function measureTextElement(el, win) {
579
+ return measureText(el, win, false);
580
+ }
581
+
582
+ // The placeholder of a text field, measured like its text would be.
583
+ function measurePlaceholder(field, win) {
584
+ return measureText(field, win, true);
585
+ }
586
+
587
+ // First match: SDK readiness, then stylesheets, then fonts. The SDK and
588
+ // stylesheet checks read every tree of the document: its own elements and
589
+ // each open shadow root inside it. Chromium gives a stylesheet that failed
590
+ // to load an empty sheet, so a sheet is also incomplete when its resource
591
+ // entry reports an HTTP error status. Each @import, in a <style> or in a
592
+ // linked or imported sheet, is a stylesheet too: incomplete while its rule
593
+ // has no sheet. A sheet whose rules cannot be read (cross-origin) has
594
+ // loaded, and its imports are not seen.
595
+ function documentMeasurability(doc) {
596
+ const body = doc.body;
597
+ const trees = [doc, ...flatSubtree(doc).filter((node) => node.shadowRoot).map((node) => node.shadowRoot)];
598
+ const everywhere = (selector) => trees.flatMap((tree) => Array.from(tree.querySelectorAll(selector)));
599
+ const declared = Boolean(body && body.hasAttribute("data-next-sdk-loading"))
600
+ || everywhere("script[src]").some((script) => SDK_LOADER_SRC.test(script.getAttribute("src")));
601
+ const ready = Boolean(body && body.getAttribute("data-next-sdk-loading") === "false") || doc.documentElement.classList.contains("next-display-ready");
602
+ if (declared && !ready) return { measurable: false, reason: "sdk_not_ready" };
603
+ const timing = doc.defaultView ? doc.defaultView.performance : null;
604
+ const failed = (url) => Boolean(timing) && Boolean(url) && timing.getEntriesByName(url).some((entry) => entry.responseStatus >= 400);
605
+ const seen = new Set();
606
+ const importsIncomplete = (sheet) => {
607
+ if (!sheet || seen.has(sheet)) return false;
608
+ seen.add(sheet);
609
+ let rules;
610
+ try {
611
+ rules = Array.from(sheet.cssRules || []);
612
+ } catch {
613
+ return false;
614
+ }
615
+ return rules.some((rule) => rule.type === 3 && (!rule.styleSheet || failed(rule.styleSheet.href) || importsIncomplete(rule.styleSheet)));
616
+ };
617
+ const linkIncomplete = (link) => !link.sheet || failed(link.href) || importsIncomplete(link.sheet);
618
+ if (everywhere("link[rel=stylesheet]").some(linkIncomplete) || everywhere("style").some((style) => importsIncomplete(style.sheet))) return { measurable: false, reason: "styles_incomplete" };
619
+ if (doc.fonts.status !== "loaded") return { measurable: false, reason: "fonts_pending" };
620
+ return { measurable: true };
621
+ }
622
+
623
+ return {
624
+ version: "contrast/v1",
625
+ parseComputedColor,
626
+ toSrgb,
627
+ relativeLuminance,
628
+ contrastRatio,
629
+ compositeOver,
630
+ isLargeText,
631
+ requiredRatio,
632
+ meetsRequirement,
633
+ displayRatio,
634
+ deriveElementMeasurement,
635
+ isTextBearing,
636
+ measureTextElement,
637
+ measurePlaceholder,
638
+ documentMeasurability,
639
+ // The flat-tree walk the measurement uses, for page scripts that read
640
+ // the same trees.
641
+ shadowRootOf,
642
+ flatParent,
643
+ flatClosest,
644
+ flatContains,
645
+ flatSubtree,
646
+ isVisible,
647
+ selectorPath,
648
+ };
649
+ }