@luigisbox/luma 0.0.0-stage → 0.2.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 (145) hide show
  1. package/LICENSE.md +90 -0
  2. package/README.md +615 -2
  3. package/a11y/audit.mjs +735 -0
  4. package/a11y/axe.mjs +73 -0
  5. package/a11y/report.mjs +347 -0
  6. package/a11y/run.mjs +256 -0
  7. package/a11y/wcag.mjs +233 -0
  8. package/bin/config.mjs +60 -0
  9. package/bin/luma.mjs +99 -0
  10. package/bin/paths.mjs +11 -0
  11. package/bin/tools.mjs +43 -0
  12. package/build/baseline.d.mts +8 -0
  13. package/build/baseline.mjs +167 -0
  14. package/build/tooling.mjs +102 -0
  15. package/build/vite.mjs +507 -0
  16. package/chrome/block.mjs +8 -0
  17. package/chrome/run.mjs +150 -0
  18. package/docs/ACCESSIBILITY.md +501 -0
  19. package/docs/INTEGRATION-PERFORMANCE.md +213 -0
  20. package/docs/PARITY.md +946 -0
  21. package/footprint/model.mjs +184 -0
  22. package/footprint/run.mjs +331 -0
  23. package/package.json +58 -4
  24. package/parity/alignment.mjs +109 -0
  25. package/parity/archive.mjs +223 -0
  26. package/parity/args.mjs +137 -0
  27. package/parity/behaviour.mjs +379 -0
  28. package/parity/compare.mjs +985 -0
  29. package/parity/engine.mjs +848 -0
  30. package/parity/hints.mjs +132 -0
  31. package/parity/hold.mjs +54 -0
  32. package/parity/init.mjs +703 -0
  33. package/parity/map.mjs +640 -0
  34. package/parity/owner.mjs +170 -0
  35. package/parity/pairing.mjs +110 -0
  36. package/parity/pixels.mjs +33 -0
  37. package/parity/probe.mjs +1239 -0
  38. package/parity/report.mjs +236 -0
  39. package/parity/run.mjs +267 -0
  40. package/snippet/early-hold.js +64 -0
  41. package/snippet/early-hold.min.js +1 -0
  42. package/src/analytics/chain.ts +137 -0
  43. package/src/analytics/collect.ts +64 -0
  44. package/src/analytics/datalayer.ts +67 -0
  45. package/src/analytics/from-ga4.ts +68 -0
  46. package/src/analytics/from-luma.ts +21 -0
  47. package/src/analytics/index.ts +24 -0
  48. package/src/analytics/outbox.ts +118 -0
  49. package/src/analytics/page.ts +46 -0
  50. package/src/analytics/pageview.ts +82 -0
  51. package/src/analytics/ref.ts +19 -0
  52. package/src/analytics/setup.ts +41 -0
  53. package/src/analytics/start.ts +53 -0
  54. package/src/analytics/triggers.ts +239 -0
  55. package/src/analytics/user.ts +12 -0
  56. package/src/analytics/wire.ts +67 -0
  57. package/src/api/ai.ts +98 -0
  58. package/src/api/index.ts +87 -0
  59. package/src/api/token-key.ts +42 -0
  60. package/src/api/token.ts +312 -0
  61. package/src/core/a11y-keys.ts +60 -0
  62. package/src/core/a11y.ts +160 -0
  63. package/src/core/boot-state.ts +6 -0
  64. package/src/core/color.ts +42 -0
  65. package/src/core/context.ts +108 -0
  66. package/src/core/early-rules.ts +96 -0
  67. package/src/core/entries.ts +25 -0
  68. package/src/core/fold.ts +7 -0
  69. package/src/core/health.ts +26 -0
  70. package/src/core/hold.ts +74 -0
  71. package/src/core/i18n.ts +66 -0
  72. package/src/core/image-load.ts +102 -0
  73. package/src/core/image-tags.ts +26 -0
  74. package/src/core/images.ts +170 -0
  75. package/src/core/integration.ts +126 -0
  76. package/src/core/kept.ts +25 -0
  77. package/src/core/listings.ts +100 -0
  78. package/src/core/locales.ts +14 -0
  79. package/src/core/own.ts +49 -0
  80. package/src/core/page.ts +14 -0
  81. package/src/core/prefetch.ts +88 -0
  82. package/src/core/recommenders.ts +166 -0
  83. package/src/core/reconciler.ts +397 -0
  84. package/src/core/result.ts +73 -0
  85. package/src/core/routes.ts +115 -0
  86. package/src/core/scheduler.ts +83 -0
  87. package/src/core/sense.ts +107 -0
  88. package/src/core/speculation.ts +59 -0
  89. package/src/core/stash.ts +38 -0
  90. package/src/core/storage.ts +45 -0
  91. package/src/core/styles.ts +106 -0
  92. package/src/core/support.ts +12 -0
  93. package/src/core/takeover.ts +220 -0
  94. package/src/core/templates.ts +74 -0
  95. package/src/core/tiles.ts +137 -0
  96. package/src/core/turbo.ts +64 -0
  97. package/src/core/url.ts +164 -0
  98. package/src/core/widget-inits.ts +40 -0
  99. package/src/env.d.ts +3 -0
  100. package/src/lit.ts +11 -0
  101. package/src/locales/cs.json +94 -0
  102. package/src/locales/de.json +88 -0
  103. package/src/locales/en.json +88 -0
  104. package/src/main.ts +493 -0
  105. package/src/types.ts +778 -0
  106. package/src/widgets/autocomplete/index.ts +1387 -0
  107. package/src/widgets/autocomplete/style-autocomplete.css +266 -0
  108. package/src/widgets/autocomplete/template-autocomplete-box.ts +61 -0
  109. package/src/widgets/autocomplete/template-autocomplete-empty.ts +53 -0
  110. package/src/widgets/autocomplete/template-autocomplete-result-default.ts +82 -0
  111. package/src/widgets/autocomplete/template-autocomplete-sheet.ts +74 -0
  112. package/src/widgets/listing/element.ts +1772 -0
  113. package/src/widgets/listing/flow.ts +30 -0
  114. package/src/widgets/listing/instant.ts +133 -0
  115. package/src/widgets/listing/intent.ts +193 -0
  116. package/src/widgets/listing/query.ts +298 -0
  117. package/src/widgets/listing/stash.ts +97 -0
  118. package/src/widgets/listing/state.ts +131 -0
  119. package/src/widgets/listing/view.ts +257 -0
  120. package/src/widgets/plp/index.ts +11 -0
  121. package/src/widgets/plp/route.ts +263 -0
  122. package/src/widgets/recommender/index.ts +261 -0
  123. package/src/widgets/recommender/style-recommender.css +26 -0
  124. package/src/widgets/recommender/template-recommender-box-default.ts +11 -0
  125. package/src/widgets/recommender/template-recommender-popup.ts +12 -0
  126. package/src/widgets/recommender/template-recommender-result-default.ts +10 -0
  127. package/src/widgets/search/assets.ts +7 -0
  128. package/src/widgets/search/first.ts +15 -0
  129. package/src/widgets/search/form-url.ts +59 -0
  130. package/src/widgets/search/in-place.ts +226 -0
  131. package/src/widgets/search/index.ts +16 -0
  132. package/src/widgets/search/range.ts +260 -0
  133. package/src/widgets/search/route.ts +32 -0
  134. package/src/widgets/search/speculate.ts +202 -0
  135. package/src/widgets/search/style-search.css +65 -0
  136. package/src/widgets/search/template-search-box.ts +17 -0
  137. package/src/widgets/search/template-search-facets-default.ts +73 -0
  138. package/src/widgets/search/template-search-header.ts +14 -0
  139. package/src/widgets/search/template-search-pagination.ts +12 -0
  140. package/src/widgets/search/template-search-range-default.ts +48 -0
  141. package/src/widgets/search/template-search-result-default.ts +13 -0
  142. package/src/widgets/search/template-search-results-default.ts +8 -0
  143. package/tasks/model.mjs +116 -0
  144. package/tasks/run.mjs +305 -0
  145. package/tsconfig.json +14 -0
@@ -0,0 +1,985 @@
1
+ // The comparison side of `yarn luma parity` (parity/run.mjs): what the browser
2
+ // measured on their page and on ours (parity/probe.mjs), turned into checks,
3
+ // differences, scores and a verdict. Pure functions, no browser, no I/O:
4
+ // Luma's unit tests run them on fixed inputs.
5
+ //
6
+ // Values are computed styles as the browser returned them, whole: a
7
+ // difference shows both strings in full (a `box-shadow` cut after its first
8
+ // layer reads Tailwind's transparent ring and nothing else). Numbers are
9
+ // compared with a tolerance per kind: px for lengths and boxes, CIEDE2000 for
10
+ // colours (alpha apart), exact for everything else.
11
+ //
12
+ // Every difference has an owner (parity/owner.mjs): the template's (an agent
13
+ // fixes it: `findings`, ranked) or the data's (the client's: the catalog, the
14
+ // API). The verdict is the convergence of the template: its score at or over
15
+ // the target, no CRITICAL or MAJOR template difference, the map's coverage.
16
+ import { attribute, decide } from './owner.mjs';
17
+
18
+ /** Tolerances (the map's `tolerance` overrides each). */
19
+ export const TOLERANCE = Object.freeze({
20
+ /** Lengths and boxes, in CSS px: layout positions are multiples of 1/64 px, rounding stays under this. */
21
+ px: 0.5,
22
+ /** CIEDE2000: about 2.3 is a just noticeable difference. */
23
+ deltaE: 2.3,
24
+ /** Alpha channel, 0-1: a translucent colour and an opaque one are different findings. */
25
+ alpha: 0.02,
26
+ /** Per-pixel colour threshold of the pixel diff (pixelmatch's `threshold`, 0-1). */
27
+ pixel: 0.1,
28
+ /** Share of a component's pixels allowed to differ before the pixel diff counts as a difference. */
29
+ pixels: 0.02,
30
+ });
31
+
32
+ /** Weights of the score: a property is one check; a missing element or a wrong column count weighs this much. */
33
+ export const WEIGHT = Object.freeze({ check: 1, critical: 5, pixels: 2 });
34
+
35
+ /**
36
+ * Severities: CRITICAL (a missing or extra element, a text, a column count),
37
+ * MAJOR (any other property out of tolerance), MINOR (what a shopper hardly
38
+ * sees: a box off by at most 1px, a colour within ΔE 5 of theirs, the
39
+ * pointer, a transition, the pixel share, a second signal). The convergence
40
+ * verdict ends when no CRITICAL or MAJOR template difference remains.
41
+ */
42
+ export const MINOR = Object.freeze({ px: 1, deltaE: 5, props: ['cursor', 'transition'], gap: 2 });
43
+ /** The space between consecutive siblings of a repeated part (facet groups, facet values, tiles): MINOR up to MINOR.gap px, then MAJOR. */
44
+ export const SIBLING = Object.freeze(['sibling-row-gap', 'sibling-column-gap']);
45
+ const BOX = new Set(['x', 'y', 'width', 'height']);
46
+ /** MINOR, by the rule above. @param {string} prop @param {{ delta: string, px?: number, deltaE?: number } | null} d */
47
+ export const isMinor = (prop, d) => {
48
+ if (!d) return false;
49
+ if (MINOR.props.includes(prop)) return true;
50
+ if (SIBLING.includes(prop)) return d.px != null && d.px <= MINOR.gap;
51
+ if (BOX.has(prop) || ['columnGap', 'rowGap', 'itemWidth'].includes(prop)) return d.px != null && d.px <= MINOR.px;
52
+ if (kindOf(prop) === 'color') return d.deltaE != null && d.deltaE <= MINOR.deltaE && !/alpha/.test(d.delta);
53
+ return false;
54
+ };
55
+ const weightOf = (severity) => (severity === 'critical' ? WEIGHT.critical : WEIGHT.check);
56
+
57
+ // ------------------------------------------------------------------ properties
58
+
59
+ /** The computed properties measured on every element (longhands: what a shorthand hides is read on its own). */
60
+ export const PROPS = Object.freeze([
61
+ 'font-family', 'font-size', 'font-weight', 'font-style', 'line-height', 'letter-spacing', 'word-spacing',
62
+ 'text-transform', 'text-align', 'white-space', 'text-overflow', '-webkit-line-clamp', 'font-variant-numeric',
63
+ 'text-decoration-line', 'text-decoration-style', 'text-decoration-color', 'text-decoration-thickness',
64
+ 'color', 'background-color', 'background-image', 'opacity', 'visibility', 'filter',
65
+ 'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width',
66
+ 'border-top-style', 'border-right-style', 'border-bottom-style', 'border-left-style',
67
+ 'border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color',
68
+ 'border-top-left-radius', 'border-top-right-radius', 'border-bottom-right-radius', 'border-bottom-left-radius',
69
+ 'box-shadow', 'text-shadow', 'outline-style', 'outline-width', 'outline-color',
70
+ 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
71
+ 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
72
+ 'display', 'position', 'flex-direction', 'flex-wrap', 'align-items', 'justify-content', 'row-gap', 'column-gap',
73
+ 'grid-template-columns', 'object-fit', 'object-position', 'aspect-ratio', 'overflow-x', 'overflow-y',
74
+ 'transform', 'transition', 'cursor',
75
+ ]);
76
+
77
+ const COLOR = new Set(['color', 'background-color', 'text-decoration-color', 'outline-color', 'border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color']);
78
+ const SHADOW = new Set(['box-shadow', 'text-shadow']);
79
+ const LENGTH = new Set([
80
+ 'font-size', 'line-height', 'letter-spacing', 'word-spacing', 'text-decoration-thickness', 'outline-width', 'row-gap', 'column-gap',
81
+ 'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width',
82
+ 'border-top-left-radius', 'border-top-right-radius', 'border-bottom-right-radius', 'border-bottom-left-radius',
83
+ 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
84
+ ]);
85
+ /** What a property is compared as. @param {string} prop */
86
+ export const kindOf = (prop) =>
87
+ COLOR.has(prop) ? 'color' : SHADOW.has(prop) ? 'shadow' : LENGTH.has(prop) ? 'length' : prop === 'font-family' ? 'family' : prop === 'grid-template-columns' ? 'tracks' : prop === 'transform' ? 'transform' : 'string';
88
+
89
+ /** Property groups, for the report's wording and for `ignore` (a group name ignores all of it). */
90
+ export const GROUPS = Object.freeze({
91
+ font: ['font-family', 'font-size', 'font-weight', 'font-style', 'line-height', 'letter-spacing', 'word-spacing', 'text-transform', 'text-align', 'white-space', 'text-overflow', '-webkit-line-clamp', 'font-variant-numeric', 'rendered-font'],
92
+ decoration: ['text-decoration-line', 'text-decoration-style', 'text-decoration-color', 'text-decoration-thickness'],
93
+ colour: ['color', 'background-color', 'background-image', 'opacity', 'filter'],
94
+ border: ['border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width', 'border-top-style', 'border-right-style', 'border-bottom-style', 'border-left-style', 'border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color', 'outline-style', 'outline-width', 'outline-color'],
95
+ radius: ['border-top-left-radius', 'border-top-right-radius', 'border-bottom-right-radius', 'border-bottom-left-radius'],
96
+ shadow: ['box-shadow', 'text-shadow'],
97
+ spacing: ['padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', 'row-gap', 'column-gap', 'sibling-row-gap', 'sibling-column-gap', 'sibling-direction'],
98
+ layout: ['display', 'position', 'flex-direction', 'flex-wrap', 'align-items', 'justify-content', 'grid-template-columns', 'object-fit', 'object-position', 'aspect-ratio', 'overflow-x', 'overflow-y', 'visibility'],
99
+ motion: ['transform', 'transition'],
100
+ box: ['x', 'y', 'width', 'height'],
101
+ });
102
+ const GROUP_OF = Object.fromEntries(Object.entries(GROUPS).flatMap(([g, ps]) => ps.map((p) => [p, g])));
103
+ /** @param {string} prop */
104
+ export const groupOf = (prop) => GROUP_OF[prop] ?? 'other';
105
+
106
+ // ------------------------------------------------------------------ colours
107
+
108
+ const clamp01 = (x) => Math.min(1, Math.max(0, x));
109
+ /**
110
+ * A CSS colour as [r, g, b, a] (0-255, alpha 0-1), or null. Computed styles
111
+ * serialize sRGB colours as rgb()/rgba() (Chrome: `rgb(0 0 0 / 0.5)` too) or
112
+ * color(srgb …); other spaces (oklch, lab) come with the browser's own sRGB
113
+ * reading (probe.mjs), which wins when present.
114
+ * @param {string} s
115
+ * @returns {[number, number, number, number] | null}
116
+ */
117
+ export const parseColor = (s) => {
118
+ if (s == null) return null;
119
+ const v = String(s).trim().toLowerCase();
120
+ if (v === 'transparent') return [0, 0, 0, 0];
121
+ let m = /^#([0-9a-f]{3,8})$/.exec(v);
122
+ if (m) {
123
+ const h = m[1];
124
+ const x = h.length <= 4 ? [...h].map((c) => parseInt(c + c, 16)) : h.match(/../g).map((c) => parseInt(c, 16));
125
+ if (x.length !== 3 && x.length !== 4) return null;
126
+ return [x[0], x[1], x[2], x.length === 4 ? x[3] / 255 : 1];
127
+ }
128
+ m = /^rgba?\(\s*([^)]+)\)$/.exec(v);
129
+ if (m) {
130
+ const parts = m[1].split(/[\s,/]+/).filter(Boolean);
131
+ if (parts.length < 3) return null;
132
+ const ch = parts.slice(0, 3).map((p) => (p.endsWith('%') ? (parseFloat(p) * 255) / 100 : parseFloat(p)));
133
+ const a = parts[3] == null ? 1 : parts[3].endsWith('%') ? parseFloat(parts[3]) / 100 : parseFloat(parts[3]);
134
+ if ([...ch, a].some((n) => !Number.isFinite(n))) return null;
135
+ return [ch[0], ch[1], ch[2], clamp01(a)];
136
+ }
137
+ m = /^color\(\s*srgb\s+([^)]+)\)$/.exec(v);
138
+ if (m) {
139
+ const parts = m[1].split(/[\s/]+/).filter(Boolean);
140
+ const ch = parts.slice(0, 3).map((p) => (p.endsWith('%') ? parseFloat(p) / 100 : parseFloat(p)) * 255);
141
+ const a = parts[3] == null ? 1 : parseFloat(parts[3]) / (parts[3].endsWith('%') ? 100 : 1);
142
+ if ([...ch, a].some((n) => !Number.isFinite(n))) return null;
143
+ return [ch[0], ch[1], ch[2], clamp01(a)];
144
+ }
145
+ return null;
146
+ };
147
+
148
+ /** sRGB (0-255) to CIE Lab (D65). @param {number[]} rgb */
149
+ export const toLab = ([r, g, b]) => {
150
+ const lin = (c) => {
151
+ const x = c / 255;
152
+ return x <= 0.04045 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
153
+ };
154
+ const [R, G, B] = [lin(r), lin(g), lin(b)];
155
+ const X = (R * 0.4124564 + G * 0.3575761 + B * 0.1804375) / 0.95047;
156
+ const Y = R * 0.2126729 + G * 0.7151522 + B * 0.072175;
157
+ const Z = (R * 0.0193339 + G * 0.119192 + B * 0.9503041) / 1.08883;
158
+ const f = (t) => (t > 216 / 24389 ? Math.cbrt(t) : (24389 / 27 * t + 16) / 116);
159
+ const [fx, fy, fz] = [f(X), f(Y), f(Z)];
160
+ return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
161
+ };
162
+
163
+ /** CIEDE2000 colour difference of two Lab colours. @param {number[]} lab1 @param {number[]} lab2 */
164
+ export const deltaE2000 = ([L1, a1, b1], [L2, a2, b2]) => {
165
+ const rad = Math.PI / 180;
166
+ const C1 = Math.hypot(a1, b1);
167
+ const C2 = Math.hypot(a2, b2);
168
+ const Cm = (C1 + C2) / 2;
169
+ const G = 0.5 * (1 - Math.sqrt(Cm ** 7 / (Cm ** 7 + 25 ** 7)));
170
+ const a1p = (1 + G) * a1;
171
+ const a2p = (1 + G) * a2;
172
+ const C1p = Math.hypot(a1p, b1);
173
+ const C2p = Math.hypot(a2p, b2);
174
+ const h = (b, a) => (b === 0 && a === 0 ? 0 : ((Math.atan2(b, a) / rad) + 360) % 360);
175
+ const h1p = h(b1, a1p);
176
+ const h2p = h(b2, a2p);
177
+ const dLp = L2 - L1;
178
+ const dCp = C2p - C1p;
179
+ let dhp = 0;
180
+ if (C1p * C2p !== 0) {
181
+ dhp = h2p - h1p;
182
+ if (dhp > 180) dhp -= 360;
183
+ else if (dhp < -180) dhp += 360;
184
+ }
185
+ const dHp = 2 * Math.sqrt(C1p * C2p) * Math.sin((dhp * rad) / 2);
186
+ const Lmp = (L1 + L2) / 2;
187
+ const Cmp = (C1p + C2p) / 2;
188
+ let hmp = h1p + h2p;
189
+ if (C1p * C2p !== 0) {
190
+ if (Math.abs(h1p - h2p) > 180) hmp += h1p + h2p < 360 ? 360 : -360;
191
+ hmp /= 2;
192
+ }
193
+ const T = 1 - 0.17 * Math.cos((hmp - 30) * rad) + 0.24 * Math.cos(2 * hmp * rad) + 0.32 * Math.cos((3 * hmp + 6) * rad) - 0.2 * Math.cos((4 * hmp - 63) * rad);
194
+ const dTheta = 30 * Math.exp(-(((hmp - 275) / 25) ** 2));
195
+ const Rc = 2 * Math.sqrt(Cmp ** 7 / (Cmp ** 7 + 25 ** 7));
196
+ const Sl = 1 + (0.015 * (Lmp - 50) ** 2) / Math.sqrt(20 + (Lmp - 50) ** 2);
197
+ const Sc = 1 + 0.045 * Cmp;
198
+ const Sh = 1 + 0.015 * Cmp * T;
199
+ const Rt = -Math.sin(2 * dTheta * rad) * Rc;
200
+ return Math.sqrt((dLp / Sl) ** 2 + (dCp / Sc) ** 2 + (dHp / Sh) ** 2 + Rt * (dCp / Sc) * (dHp / Sh));
201
+ };
202
+
203
+ /** #rrggbb (with @alpha when translucent): how a colour is named in the report, next to the raw value. @param {number[] | null} c */
204
+ export const hex = (c) => {
205
+ if (!c) return '';
206
+ const h = `#${c.slice(0, 3).map((x) => Math.round(x).toString(16).padStart(2, '0')).join('')}`;
207
+ return c[3] < 1 ? `${h} @${Math.round(c[3] * 1000) / 1000}` : h;
208
+ };
209
+
210
+ /**
211
+ * Two colours: equal, or how they differ. Fully transparent colours are equal
212
+ * whatever their channels; alpha is compared on its own.
213
+ * @param {{ raw: string, rgba?: number[] } | string} a @param {{ raw: string, rgba?: number[] } | string} b @param {typeof TOLERANCE} tol
214
+ */
215
+ export const compareColor = (a, b, tol = TOLERANCE) => {
216
+ const ca = colorOf(a);
217
+ const cb = colorOf(b);
218
+ if (!ca || !cb) return rawOf(a) === rawOf(b) ? null : { delta: 'different' };
219
+ if (ca[3] === 0 && cb[3] === 0) return null;
220
+ const dA = Math.abs(ca[3] - cb[3]);
221
+ const dE = deltaE2000(toLab(ca), toLab(cb));
222
+ const out = [];
223
+ if (dE > tol.deltaE) out.push(`ΔE ${round(dE, 1)}`);
224
+ if (dA > tol.alpha) out.push(`alpha ${round(ca[3], 3)} vs ${round(cb[3], 3)}`);
225
+ return out.length ? { delta: out.join(', '), deltaE: round(dE, 2) } : null;
226
+ };
227
+ const colorOf = (v) => (v && typeof v === 'object' ? (v.rgba ?? parseColor(v.raw)) : parseColor(v));
228
+ const rawOf = (v) => (v && typeof v === 'object' ? v.raw : v);
229
+
230
+ // ------------------------------------------------------------------ other kinds
231
+
232
+ /** @param {number} x @param {number} d */
233
+ export const round = (x, d = 2) => {
234
+ const f = 10 ** d;
235
+ return Math.round(x * f) / f;
236
+ };
237
+
238
+ /** Splits a value at top-level commas (not those inside parentheses). @param {string} s */
239
+ export const splitTop = (s) => {
240
+ const out = [];
241
+ let depth = 0;
242
+ let cur = '';
243
+ for (const ch of String(s)) {
244
+ if (ch === '(') depth++;
245
+ if (ch === ')') depth--;
246
+ if (ch === ',' && depth === 0) {
247
+ out.push(cur.trim());
248
+ cur = '';
249
+ } else cur += ch;
250
+ }
251
+ if (cur.trim()) out.push(cur.trim());
252
+ return out;
253
+ };
254
+
255
+ /** Tokens of a value: functions (rgb(...)) kept whole. @param {string} s */
256
+ const tokens = (s) => String(s).match(/[a-z-]+\([^()]*(?:\([^()]*\)[^()]*)*\)|[^\s]+/gi) ?? [];
257
+ const PX = /^(-?[\d.]+(?:e-?\d+)?)px$/i;
258
+ const num = (t) => {
259
+ const m = PX.exec(t);
260
+ return m ? Number(m[1]) : t === '0' ? 0 : null;
261
+ };
262
+
263
+ /** Lengths (one or more px tokens; `normal` and keywords exactly). @param {string} a @param {string} b @param {typeof TOLERANCE} tol */
264
+ export const compareLength = (a, b, tol = TOLERANCE) => {
265
+ const ta = tokens(a);
266
+ const tb = tokens(b);
267
+ if (ta.length !== tb.length) return { delta: 'different' };
268
+ let max = 0;
269
+ for (let i = 0; i < ta.length; i++) {
270
+ const [x, y] = [num(ta[i]), num(tb[i])];
271
+ if (x == null || y == null) {
272
+ if (ta[i] !== tb[i]) return { delta: 'different' };
273
+ continue;
274
+ }
275
+ max = Math.max(max, Math.abs(y - x));
276
+ }
277
+ if (max <= tol.px) return null;
278
+ // One length: the signed delta (ours - theirs); several: the largest.
279
+ const one = ta.length === 1 ? num(tb[0]) - num(ta[0]) : null;
280
+ return { delta: one != null ? `${one > 0 ? '+' : ''}${round(one, 2)}px` : `up to ${round(max, 2)}px`, px: round(max, 3) };
281
+ };
282
+
283
+ /**
284
+ * Shadows (box-shadow, text-shadow): layer by layer, colours by ΔE, lengths by px.
285
+ * @param {string} a @param {string} b @param {typeof TOLERANCE} tol
286
+ */
287
+ export const compareShadow = (a, b, tol = TOLERANCE) => {
288
+ const la = a === 'none' ? [] : splitTop(a);
289
+ const lb = b === 'none' ? [] : splitTop(b);
290
+ if (la.length !== lb.length) return { delta: `${la.length} vs ${lb.length} layers` };
291
+ for (let i = 0; i < la.length; i++) {
292
+ const ta = tokens(la[i]);
293
+ const tb = tokens(lb[i]);
294
+ const ca = ta.find((t) => parseColor(t));
295
+ const cb = tb.find((t) => parseColor(t));
296
+ if (!!ca !== !!cb) return { delta: `layer ${i + 1}: colour` };
297
+ if (ca && compareColor(ca, cb, tol)) return { delta: `layer ${i + 1}: ${compareColor(ca, cb, tol).delta}` };
298
+ const la2 = ta.filter((t) => t !== ca);
299
+ const lb2 = tb.filter((t) => t !== cb);
300
+ const d = compareLength(la2.join(' '), lb2.join(' '), tol);
301
+ if (d) return { delta: `layer ${i + 1}: ${d.delta}` };
302
+ }
303
+ return null;
304
+ };
305
+
306
+ /** font-family lists: the same families in the same order (quotes and case aside). @param {string} a @param {string} b */
307
+ export const compareFamily = (a, b) => {
308
+ const n = (s) => splitTop(s).map((f) => f.replace(/^["']|["']$/g, '').trim().toLowerCase()).join(', ');
309
+ return n(a) === n(b) ? null : { delta: 'different' };
310
+ };
311
+
312
+ /** grid-template-columns: the number of tracks, then each track's size. @param {string} a @param {string} b @param {typeof TOLERANCE} tol */
313
+ export const compareTracks = (a, b, tol = TOLERANCE) => {
314
+ const ta = a === 'none' ? [] : tokens(a);
315
+ const tb = b === 'none' ? [] : tokens(b);
316
+ if (ta.length !== tb.length) return { delta: `${ta.length} vs ${tb.length} tracks`, critical: ta.length > 0 && tb.length > 0 };
317
+ return compareLength(a, b, tol);
318
+ };
319
+
320
+ /** transform: the matrix's numbers (translation in px). @param {string} a @param {string} b @param {typeof TOLERANCE} tol */
321
+ export const compareTransform = (a, b, tol = TOLERANCE) => {
322
+ if (a === b) return null;
323
+ const n = (s) => (s === 'none' ? [1, 0, 0, 1, 0, 0] : (/matrix(?:3d)?\(([^)]+)\)/.exec(s)?.[1].split(',').map(Number) ?? null));
324
+ const [x, y] = [n(a), n(b)];
325
+ if (!x || !y || x.length !== y.length) return { delta: 'different' };
326
+ const max = Math.max(...x.map((v, i) => Math.abs(v - y[i])));
327
+ return max <= Math.max(tol.px, 0.001) ? null : { delta: 'different' };
328
+ };
329
+
330
+ /** One computed property, by its kind. @param {string} prop @param {any} a @param {any} b @param {typeof TOLERANCE} tol */
331
+ export const compareValue = (prop, a, b, tol = TOLERANCE) => {
332
+ if (a == null && b == null) return null;
333
+ if (a == null || b == null) return { delta: 'different' };
334
+ switch (kindOf(prop)) {
335
+ case 'color':
336
+ return compareColor(a, b, tol);
337
+ case 'shadow':
338
+ return compareShadow(a, b, tol);
339
+ case 'length':
340
+ return compareLength(a, b, tol);
341
+ case 'family':
342
+ return compareFamily(a, b);
343
+ case 'tracks':
344
+ return compareTracks(a, b, tol);
345
+ case 'transform':
346
+ return compareTransform(a, b, tol);
347
+ default:
348
+ return norm(a) === norm(b) ? null : { delta: 'different' };
349
+ }
350
+ };
351
+ const norm = (v) => String(v).replace(/\s+/g, ' ').trim();
352
+
353
+ /** Text as the comparison sees it: `exact` (whitespace collapsed) or `shape` (every number one placeholder). @param {string} s @param {'exact' | 'shape'} mode */
354
+ export const textOf = (s, mode) => {
355
+ const t = String(s ?? '').replace(/\s+/g, ' ').trim();
356
+ return mode === 'shape' ? t.replace(/\d(?:[\d\s.,'’]*\d)?/g, '#') : t;
357
+ };
358
+
359
+ // ------------------------------------------------------------------ elements and components
360
+
361
+ /**
362
+ * One element of theirs against ours (the component's root or one of its
363
+ * parts): presence, box, rendered font, text, every computed property.
364
+ * `a`/`b`: { present, box: {x,y,width,height}, style: {prop: value}, font, text } (probe.mjs).
365
+ * @returns {{ checks: number, weight: number, diffs: Diff[] }}
366
+ *
367
+ * @typedef {{ part: string, prop: string, theirs: any, ours: any, delta: string, severity: 'critical' | 'major' | 'minor', group: string, weight: number, owner?: 'template' | 'data', why?: string, evidence?: any, hint?: string, responsible?: any }} Diff
368
+ */
369
+ export const compareElement = (part, a, b, { tol = TOLERANCE, text = 'exact', ignore = [], root = false } = {}) => {
370
+ const out = { checks: 0, weight: 0, diffs: /** @type {Diff[]} */ ([]) };
371
+ const ignored = new Set(ignore.flatMap((p) => GROUPS[p] ?? [p]));
372
+ // One check: counted with the weight it has when it fails (critical ones weigh more), its difference if any.
373
+ const tally = (prop, theirs, ours, d, severity = 'major') => {
374
+ const sev = d?.critical || severity === 'critical' ? 'critical' : isMinor(prop, d) ? 'minor' : 'major';
375
+ const w = weightOf(sev === 'critical' ? 'critical' : severity);
376
+ out.checks += w;
377
+ if (!d) return;
378
+ out.weight += w;
379
+ out.diffs.push({ part, prop, theirs, ours, delta: d.delta, severity: sev, group: groupOf(prop), weight: w });
380
+ };
381
+ const [pa, pb] = [!!a?.present, !!b?.present];
382
+ if (!pa && !pb) return out;
383
+ if (pa !== pb) {
384
+ tally('presence', pa ? 'present' : 'missing', pb ? 'present' : 'missing', { delta: pa ? 'missing in ours' : 'only in ours' }, 'critical');
385
+ return out;
386
+ }
387
+ for (const k of ['x', 'y', 'width', 'height']) {
388
+ if (ignored.has(k) || (root && (k === 'x' || k === 'y'))) continue;
389
+ tally(k, round(a.box[k], 2), round(b.box[k], 2), compareLength(`${a.box[k]}px`, `${b.box[k]}px`, tol));
390
+ }
391
+ // A part's controls (a price filter's slider and its inputs): a kind theirs has and ours lacks is a missing element.
392
+ if (!root && !ignored.has('controls') && a.controls && Object.values(a.controls).some((n) => n > 0)) {
393
+ const missing = Object.keys(a.controls).filter((k) => a.controls[k] > 0 && !(b.controls?.[k] > 0));
394
+ tally('controls', controlsText(a.controls), controlsText(b.controls), missing.length ? { delta: `missing in ours: ${missing.join(', ')}` } : null, 'critical');
395
+ }
396
+ if (!ignored.has('rendered-font') && (a.font || b.font)) tally('rendered-font', a.font ?? '', b.font ?? '', (a.font ?? '') === (b.font ?? '') ? null : { delta: 'different' });
397
+ if (text && !ignored.has('text') && (a.text || b.text)) {
398
+ const same = textOf(a.text, text) === textOf(b.text, text);
399
+ tally('text', a.text ?? '', b.text ?? '', same ? null : { delta: text === 'shape' ? 'different wording' : 'different' }, 'critical');
400
+ }
401
+ for (const prop of PROPS) {
402
+ if (ignored.has(prop)) continue;
403
+ const [va, vb] = [a.style?.[prop], b.style?.[prop]];
404
+ if (va === undefined && vb === undefined) continue;
405
+ tally(prop, rawOf(va), rawOf(vb), compareValue(prop, va, vb, tol));
406
+ }
407
+ return out;
408
+ };
409
+
410
+ /** A part's controls as the report names them: "slider, 2 inputs". @param {Record<string, number> | null | undefined} c */
411
+ export const controlsText = (c) => {
412
+ const PLURAL = { slider: 'sliders', input: 'inputs', select: 'selects', checkbox: 'checkboxes', radio: 'radios', button: 'buttons' };
413
+ const out = Object.entries(c ?? {}).filter(([, n]) => n > 0).map(([k, n]) => (n === 1 ? k : `${n} ${PLURAL[k] ?? `${k}s`}`));
414
+ return out.length ? out.join(', ') : 'none';
415
+ };
416
+
417
+ /** The order parts appear in (top to bottom, then left to right), for the parts both sides have. @param {Record<string, any>} parts */
418
+ export const orderOf = (parts) =>
419
+ Object.entries(parts ?? {})
420
+ .filter(([, p]) => p?.present)
421
+ .sort(([na, a], [nb, b]) => (Math.abs(a.box.y - b.box.y) > 2 ? a.box.y - b.box.y : Math.abs(a.box.x - b.box.x) > 2 ? a.box.x - b.box.x : na < nb ? -1 : 1))
422
+ .map(([n]) => n);
423
+
424
+ /**
425
+ * A grid of items (columns per row, gaps, item width, the container's padding).
426
+ * `a`/`b`: { columns, columnGap, rowGap, itemWidth, padding: [t, r, b, l], items } (probe.mjs `grid`).
427
+ */
428
+ export const compareGrid = (a, b, tol = TOLERANCE) => {
429
+ const out = { checks: 0, weight: 0, diffs: /** @type {Diff[]} */ ([]) };
430
+ if (!a || !b) return out;
431
+ const add = (prop, x, y, d, critical = false) => {
432
+ const severity = critical ? 'critical' : isMinor(prop, d) ? 'minor' : 'major';
433
+ const weight = critical ? WEIGHT.critical : WEIGHT.check;
434
+ out.diffs.push({ part: 'grid', prop, theirs: x, ours: y, delta: d.delta, severity, group: 'layout', weight });
435
+ out.weight += weight;
436
+ };
437
+ out.checks += WEIGHT.critical;
438
+ if (a.columns !== b.columns) add('columns', a.columns, b.columns, { delta: `${a.columns} vs ${b.columns}` }, true);
439
+ for (const k of ['columnGap', 'rowGap', 'itemWidth']) {
440
+ if (a[k] == null && b[k] == null) continue;
441
+ out.checks += WEIGHT.check;
442
+ if (a[k] == null || b[k] == null) add(k, a[k] ?? '-', b[k] ?? '-', { delta: 'different' });
443
+ else {
444
+ const d = compareLength(`${a[k]}px`, `${b[k]}px`, tol);
445
+ if (d) add(k, round(a[k], 2), round(b[k], 2), d);
446
+ }
447
+ }
448
+ const sides = ['top', 'right', 'bottom', 'left'];
449
+ for (let i = 0; i < 4; i++) {
450
+ out.checks += WEIGHT.check;
451
+ const d = compareLength(`${a.padding?.[i] ?? 0}px`, `${b.padding?.[i] ?? 0}px`, tol);
452
+ if (d) add(`padding-${sides[i]}`, a.padding?.[i] ?? 0, b.padding?.[i] ?? 0, d);
453
+ }
454
+ return out;
455
+ };
456
+
457
+ /** The lower median. @param {number[]} xs */
458
+ const median = (xs) => (xs.length ? [...xs].sort((x, y) => x - y)[Math.floor((xs.length - 1) / 2)] : null);
459
+
460
+ /**
461
+ * How consecutive siblings lie (page boxes [x, y, width, height], probe.mjs
462
+ * `siblingsOf`): rows (a box within 2 px of the row's top joins it), the
463
+ * columns of the first row, the space between neighbours in a row and
464
+ * between rows (the median: one odd gap is not the pattern), stacked or side
465
+ * by side. Null for fewer than two.
466
+ * @param {number[][] | null | undefined} boxes
467
+ * @returns {{ n: number, columns: number, rows: number, columnGap: number | null, rowGap: number | null, stacked: boolean } | null}
468
+ */
469
+ export const layoutOf = (boxes) => {
470
+ if (!Array.isArray(boxes) || boxes.length < 2) return null;
471
+ const rows = [];
472
+ for (const b of boxes) {
473
+ const row = rows.find((r) => Math.abs(r[0][1] - b[1]) < 2);
474
+ if (row) row.push(b);
475
+ else rows.push([b]);
476
+ }
477
+ for (const r of rows) r.sort((p, q) => p[0] - q[0]);
478
+ rows.sort((p, q) => p[0][1] - q[0][1]);
479
+ const inRow = [];
480
+ for (const r of rows) for (let i = 1; i < r.length; i++) inRow.push(r[i][0] - (r[i - 1][0] + r[i - 1][2]));
481
+ const between = [];
482
+ for (let i = 1; i < rows.length; i++) between.push(rows[i][0][1] - Math.max(...rows[i - 1].map((b) => b[1] + b[3])));
483
+ const g = (v) => (v == null ? null : round(v, 2));
484
+ return { n: boxes.length, columns: rows[0].length, rows: rows.length, columnGap: g(median(inRow)), rowGap: g(median(between)), stacked: rows.every((r) => r.length === 1) };
485
+ };
486
+
487
+ /**
488
+ * The space between consecutive siblings of a repeated part or component
489
+ * (facet groups squeezed together, facet values, tiles in a column): each
490
+ * element's own box can match while the gap between one and the next does
491
+ * not (a `+` rule's margin, the parent's gap). MINOR up to MINOR.gap px,
492
+ * MAJOR beyond; stacked against side by side MAJOR. `columns`: a repeated
493
+ * component's columns per row too (a tile without a grid component),
494
+ * CRITICAL when they differ, at any viewport.
495
+ * @param {string} part @param {number[][] | null | undefined} a @param {number[][] | null | undefined} b
496
+ * @param {typeof TOLERANCE} tol @param {{ columns?: boolean }} [o]
497
+ */
498
+ export const compareSiblings = (part, a, b, tol = TOLERANCE, { columns = false } = {}) => {
499
+ const out = { checks: 0, weight: 0, diffs: /** @type {Diff[]} */ ([]) };
500
+ const [la, lb] = [layoutOf(a), layoutOf(b)];
501
+ if (!la || !lb) return out;
502
+ const add = (prop, x, y, d, critical = false) => {
503
+ const severity = critical ? 'critical' : isMinor(prop, d) ? 'minor' : 'major';
504
+ const weight = critical ? WEIGHT.critical : WEIGHT.check;
505
+ out.diffs.push({ part, prop, theirs: x, ours: y, delta: d.delta, severity, group: prop === 'columns' ? 'layout' : 'spacing', weight });
506
+ out.weight += weight;
507
+ };
508
+ if (columns) {
509
+ out.checks += WEIGHT.critical;
510
+ if (la.columns !== lb.columns) add('columns', la.columns, lb.columns, { delta: `${la.columns} vs ${lb.columns}` }, true);
511
+ } else {
512
+ out.checks += WEIGHT.check;
513
+ if (la.stacked !== lb.stacked) add('sibling-direction', la.stacked ? 'stacked' : 'side by side', lb.stacked ? 'stacked' : 'side by side', { delta: 'different' });
514
+ }
515
+ for (const [prop, k] of [['sibling-column-gap', 'columnGap'], ['sibling-row-gap', 'rowGap']]) {
516
+ if (la[k] == null || lb[k] == null) continue;
517
+ out.checks += WEIGHT.check;
518
+ const d = compareLength(`${la[k]}px`, `${lb[k]}px`, tol);
519
+ if (d) add(prop, la[k], lb[k], d);
520
+ }
521
+ return out;
522
+ };
523
+
524
+ /**
525
+ * One component instance (theirs vs ours, measured by probe.mjs `measure`):
526
+ * the root, every mapped part, the parts' order, the grid when it is one,
527
+ * and the pixel diff's share; every difference with its owner (owner.mjs:
528
+ * the template's or the data's), the score and the template's score.
529
+ * @param {{ theirs: any, ours: any, pixels?: { ratio: number } | null, hit?: any, siblings?: { theirs: number[][] | null, ours: number[][] | null, columns?: boolean } | null }} pair `hit`: the catalog record of the paired product; `siblings`: a repeated component's instances (compareSiblings)
530
+ * @param {{ tol?: typeof TOLERANCE, parts?: Record<string, { text?: 'exact' | 'shape' | false, ignore?: string[], role?: string | null }>, text?: 'exact' | 'shape' | false, ignore?: string[], component?: string, role?: string | null, gaps?: Record<string, string>, decided?: Record<string, any> }} opts
531
+ */
532
+ export const compareInstance = (pair, opts = {}) => {
533
+ const tol = { ...TOLERANCE, ...(opts.tol ?? {}) };
534
+ const res = { checks: 0, weight: 0, diffs: /** @type {Diff[]} */ ([]) };
535
+ const merge = (r) => {
536
+ res.checks += r.checks;
537
+ res.weight += r.weight;
538
+ res.diffs.push(...r.diffs);
539
+ };
540
+ const { theirs: a, ours: b } = pair;
541
+ if (!a?.present || !b?.present) {
542
+ merge(compareElement('root', a ?? { present: false }, b ?? { present: false }, { tol, root: true }));
543
+ return finish(res, pair, opts);
544
+ }
545
+ // A component without parts (a result count, a heading) is its own text.
546
+ const leaf = !a.grid && !b.grid && !Object.keys(a.parts ?? {}).length && !Object.keys(b.parts ?? {}).length;
547
+ merge(compareElement('root', a, b, { tol, text: leaf ? (opts.text ?? 'exact') : false, ignore: opts.ignore ?? [], root: true }));
548
+ const names = [...new Set([...Object.keys(a.parts ?? {}), ...Object.keys(b.parts ?? {})])].sort();
549
+ for (const n of names) {
550
+ const po = opts.parts?.[n] ?? {};
551
+ const ignore = [...(opts.ignore ?? []), ...(po.ignore ?? [])];
552
+ merge(compareElement(n, a.parts?.[n], b.parts?.[n], { tol, text: po.text ?? opts.text ?? 'exact', ignore }));
553
+ // A repeated part (facet groups, a group's values): the space between one and the next, not only its own box.
554
+ if (a.parts?.[n]?.present && b.parts?.[n]?.present && !ignore.includes('spacing') && !ignore.includes('siblings')) merge(compareSiblings(n, a.parts[n].siblings, b.parts[n].siblings, tol));
555
+ }
556
+ // A repeated component's instances (tiles, facet groups as a component): their gaps; a tile's columns when no grid is compared.
557
+ if (pair.siblings && !(opts.ignore ?? []).includes('spacing') && !(opts.ignore ?? []).includes('siblings')) merge(compareSiblings('root', pair.siblings.theirs, pair.siblings.ours, tol, { columns: !!pair.siblings.columns }));
558
+ const [oa, ob] = [orderOf(a.parts), orderOf(b.parts)];
559
+ const common = oa.filter((n) => ob.includes(n));
560
+ if (common.length > 1) {
561
+ res.checks += WEIGHT.check;
562
+ const ob2 = ob.filter((n) => common.includes(n));
563
+ if (common.join() !== ob2.join()) {
564
+ res.diffs.push({ part: 'root', prop: 'order', theirs: common.join(' > '), ours: ob2.join(' > '), delta: 'different order', severity: 'major', group: 'layout', weight: WEIGHT.check });
565
+ res.weight += WEIGHT.check;
566
+ }
567
+ }
568
+ if (a.grid || b.grid) merge(compareGrid(a.grid, b.grid, tol));
569
+ if (pair.pixels) {
570
+ res.checks += WEIGHT.pixels;
571
+ if (pair.pixels.ratio > tol.pixels) {
572
+ res.diffs.push({ part: 'root', prop: 'pixels', theirs: `${pair.pixels.size?.theirs ?? ''}`, ours: `${pair.pixels.size?.ours ?? ''}`, delta: `${round(pair.pixels.ratio * 100, 2)}% of pixels differ`, ratio: pair.pixels.ratio, severity: 'minor', group: 'pixels', weight: WEIGHT.pixels });
573
+ res.weight += WEIGHT.pixels;
574
+ }
575
+ }
576
+ return finish(res, pair, opts);
577
+ };
578
+
579
+ /** The owners (owner.mjs), the score and the template's score (the data's differences count as passed checks). */
580
+ const finish = (r, pair = {}, opts = {}) => {
581
+ const partRoles = Object.fromEntries(Object.entries(opts.parts ?? {}).map(([k, v]) => [k, v.role ?? null]));
582
+ const diffs = decide(attribute(r.diffs, { component: opts.component, role: opts.role ?? null, partRoles, hit: pair.hit ?? null, gaps: opts.gaps ?? {} }), { component: opts.component, decided: opts.decided ?? {} });
583
+ // The template's score: the data's differences and the decided ones count as passed.
584
+ const templateWeight = diffs.filter((d) => (d.owner ?? 'template') === 'template').reduce((s, d) => s + d.weight, 0);
585
+ return { ...r, diffs, templateWeight, score: scoreOf(r), templateScore: scoreOf({ checks: r.checks, weight: templateWeight }) };
586
+ };
587
+ /** 100 when nothing differs; every difference takes its weight off the checks' total; null when nothing was there to check (on neither side). @param {{ checks: number, weight: number }} r */
588
+ export const scoreOf = (r) => (r.checks ? round(100 * Math.max(0, 1 - r.weight / r.checks), 1) : null);
589
+
590
+ const SEVERITY_RANK = { critical: 0, major: 1, minor: 2 };
591
+ const FOLLOWS_COLOR = new Set(['border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color', 'outline-color', 'text-decoration-color']);
592
+
593
+ /**
594
+ * The same difference across a component's instances (every price 1px small)
595
+ * is one finding with a count: a finding per tile buries the pattern.
596
+ * @param {Diff[][]} lists one list per instance
597
+ */
598
+ export const aggregate = (lists) => {
599
+ const by = new Map();
600
+ for (const list of lists)
601
+ for (const d of list) {
602
+ // The pixel diff: one finding, its largest share (the sizes and shares differ per instance).
603
+ const key = JSON.stringify(d.prop === 'pixels' ? [d.part, d.prop, d.owner ?? ''] : [d.part, d.prop, d.theirs, d.ours, d.owner ?? '']);
604
+ const cur = by.get(key);
605
+ if (cur) {
606
+ cur.count++;
607
+ if (d.prop === 'pixels' && d.ratio > cur.ratio) Object.assign(cur, { ...d, count: cur.count, delta: `up to ${d.delta}` });
608
+ else if (d.prop === 'pixels' && !cur.delta.startsWith('up to')) cur.delta = `up to ${cur.delta}`;
609
+ } else by.set(key, { ...d, count: 1 });
610
+ }
611
+ const rank = (d) => SEVERITY_RANK[d.severity] ?? 1;
612
+ return [...by.values()].sort((x, y) => rank(x) - rank(y) || y.count - x.count || (x.part < y.part ? -1 : x.part > y.part ? 1 : 0) || (x.prop < y.prop ? -1 : x.prop > y.prop ? 1 : 0));
613
+ };
614
+
615
+ /**
616
+ * A component at one viewport, all its instances: the mean score and
617
+ * template score, the aggregated differences, the instances' own scores.
618
+ * `unpaired`: their products the API did not find (not in the catalog):
619
+ * each a data difference, and an instance scoring 0 in the overall score
620
+ * (none in the template's).
621
+ * @param {{ component: string, page: string, viewport: number, pairs: { key: string, theirs: any, ours: any, pixels?: any, hit?: any }[], unpaired?: string[] }} c
622
+ * @param {Parameters<typeof compareInstance>[1]} opts
623
+ */
624
+ export const compareComponent = (c, opts = {}) => {
625
+ const instances = c.pairs.map((p) => ({ key: p.key, ...compareInstance(p, { component: baseOf(c.component), ...opts }), pixels: p.pixels ?? null }));
626
+ const unpaired = c.unpaired ?? [];
627
+ const mean = (xs) => (xs.length ? round(xs.reduce((s, x) => s + x, 0) / xs.length, 1) : null);
628
+ const scores = instances.map((i) => i.score).filter((s) => s != null);
629
+ const score = mean(scores.length ? [...scores, ...unpaired.map(() => 0)] : []);
630
+ const templateScore = mean(instances.map((i) => i.templateScore).filter((s) => s != null));
631
+ const lost = unpaired.map((id) => ({ part: 'root', prop: 'unpaired', theirs: id, ours: 'not found by the API', delta: 'their product is not in the catalog', severity: 'critical', group: 'data', weight: WEIGHT.critical, owner: 'data', why: 'not in the catalog (or not under the pairing field: products.field)', count: 1 }));
632
+ return {
633
+ component: c.component,
634
+ page: c.page,
635
+ viewport: c.viewport,
636
+ score,
637
+ templateScore,
638
+ instances: instances.map(({ key, score: s, templateScore: ts, checks, weight, pixels }) => ({ key, score: s, templateScore: ts, checks, weight, pixels: pixels ? { ratio: round(pixels.ratio, 4), images: pixels.images ?? null } : null })),
639
+ diffs: [...lost, ...aggregate(instances.map((i) => i.diffs))],
640
+ };
641
+ };
642
+
643
+ /** A result's component as the map names it: `tile@drawer-open:hover` is `tile` (in a state, hovered). @param {string} name */
644
+ export const baseOf = (name) => String(name).replace(/:hover$/, '').replace(/@[\w-]+$/, '');
645
+
646
+ /**
647
+ * The collateral check of one page (docs/PARITY.md, "The collateral check"):
648
+ * their page without us against ours, outside our components (probe.mjs
649
+ * `blocksOf` on both). A block of theirs missing in ours (a widget removed,
650
+ * hidden) is CRITICAL, and so is a child of it; a block's box otherwise (a
651
+ * header moved, a block collapsed) MAJOR: its x, width and height, and its y
652
+ * when it lies above our components (below them it moves with our
653
+ * components' height, which their own checks compare). A container of
654
+ * theirs (their listing's column: it held what ours replaces) may be gone;
655
+ * when it is there, its x, width and (above) y. A block only in ours is
656
+ * MAJOR. The pixels of each block both sides show at one size, a second
657
+ * signal (MINOR). The layout shift (CLS) ours adds over theirs, beyond
658
+ * `cls`: MAJOR, with the elements that moved.
659
+ * @param {{ blocks: any[], top: number | null, cls?: { value: number, shifts: any[] } | null }} a their page without us (`cls`: the shifts of their elements above our components)
660
+ * @param {{ blocks: any[], top: number | null, cls?: { value: number, shifts: any[] } | null }} b ours
661
+ * @param {{ tol?: typeof TOLERANCE, pixels?: Record<string, number>, cls?: number | null }} [o] `pixels`: per block path, the share of its pixels that differ
662
+ */
663
+ export const compareCollateral = (a, b, { tol = TOLERANCE, pixels = {}, cls = 0.02, around = null } = {}) => {
664
+ const t = { ...TOLERANCE, ...tol };
665
+ const out = { checks: 0, weight: 0, diffs: /** @type {Diff[]} */ ([]), layout: /** @type {any} */ (null) };
666
+ const name = (path) => String(path).replace(/^body > /, '');
667
+ const add = (path, prop, theirs, ours, d, severity) => {
668
+ const sev = severity ?? (isMinor(prop, d) ? 'minor' : 'major');
669
+ const weight = sev === 'critical' ? WEIGHT.critical : prop === 'pixels' ? WEIGHT.pixels : WEIGHT.check;
670
+ out.diffs.push({ part: name(path), prop, theirs, ours, delta: d.delta, severity: sev, group: 'collateral', weight, ...(d.ratio != null ? { ratio: d.ratio } : {}) });
671
+ out.weight += weight;
672
+ };
673
+ const check = (w = WEIGHT.check) => (out.checks += w);
674
+ const ours = new Map((b?.blocks ?? []).map((x) => [x.path, x]));
675
+ const theirs = new Map((a?.blocks ?? []).map((x) => [x.path, x]));
676
+ // Above our components, on ours: it keeps its place (their header, a breadcrumb), so its y is compared.
677
+ const above = (x) => b?.top == null || x.box.y + x.box.height <= b.top + 1;
678
+ const boxes = (path, x, y, props) => {
679
+ for (const k of props) {
680
+ if (k === 'y' && !(above(y) && above(x))) continue;
681
+ check();
682
+ const d = compareLength(`${x.box[k]}px`, `${y.box[k]}px`, t);
683
+ if (d) add(path, k, round(x.box[k], 2), round(y.box[k], 2), d);
684
+ }
685
+ };
686
+ // Their containers ours has not (their listing's column, hidden while ours shows): what is in them is replaced.
687
+ const replaced = (a?.blocks ?? []).filter((x) => x.kind === 'container' && x.present && !ours.get(x.path)?.present).map((x) => `${x.path} > `);
688
+ for (const x of a?.blocks ?? []) {
689
+ const y = ours.get(x.path);
690
+ if (replaced.some((p) => x.path.startsWith(p))) continue;
691
+ if (!x.present) {
692
+ // Hidden on their page, shown on ours (something of ours unhid it).
693
+ if (y?.present && y.kind === 'outside') {
694
+ check(WEIGHT.critical);
695
+ add(x.path, 'presence', 'hidden', 'shown', { delta: 'shown only in ours' });
696
+ }
697
+ continue;
698
+ }
699
+ if (x.kind === 'container') {
700
+ // Their listing's column: it held what ours replaces; gone or there, both fine. There: in its place.
701
+ if (y?.present) boxes(x.path, x, y, ['x', 'width', 'y']);
702
+ continue;
703
+ }
704
+ check(WEIGHT.critical);
705
+ if (!y?.present) {
706
+ add(x.path, 'presence', 'present', 'missing', { delta: 'their block is missing in ours' }, 'critical');
707
+ continue;
708
+ }
709
+ // Ours holds one of our components in it (our listing in their content column): its height is our component's.
710
+ boxes(x.path, x, y, y.kind === 'outside' ? ['x', 'y', 'width', 'height'] : ['x', 'y', 'width']);
711
+ // Its children: a widget inside their header (a menu, a cart) gone from ours (where ours holds ours in it: its blocks).
712
+ const kids = new Set(y.kind === 'outside' ? (y.children ?? []) : (b?.blocks ?? []).filter((z) => z.present).map((z) => z.path));
713
+ for (const c of x.children ?? []) {
714
+ check(WEIGHT.critical);
715
+ if (!kids.has(c)) add(c, 'presence', 'present', 'missing', { delta: 'their element is missing in ours' }, 'critical');
716
+ }
717
+ if (y.kind !== 'outside') continue;
718
+ if (pixels[x.path] != null) {
719
+ check(WEIGHT.pixels);
720
+ if (pixels[x.path] > t.pixels) add(x.path, 'pixels', '', '', { delta: `${round(pixels[x.path] * 100, 2)}% of pixels differ`, ratio: pixels[x.path] }, 'minor');
721
+ }
722
+ }
723
+ for (const y of b?.blocks ?? []) {
724
+ if (!y.present || y.kind !== 'outside' || theirs.get(y.path)?.present) continue;
725
+ if (theirs.has(y.path)) continue; // hidden on theirs: counted above
726
+ check();
727
+ add(y.path, 'presence', 'missing', 'present', { delta: 'a block only in ours, outside our components' });
728
+ }
729
+ if (cls != null && a?.cls && b?.cls) {
730
+ check();
731
+ const more = b.cls.value - a.cls.value;
732
+ if (more > cls) {
733
+ const moved = [...new Set(b.cls.shifts.flatMap((s) => s.nodes))].slice(0, 4).join(', ');
734
+ add('page', 'cls', round(a.cls.value, 4), round(b.cls.value, 4), { delta: `+${round(more, 4)} layout shift${moved ? ` (moved: ${moved})` : ''}` });
735
+ }
736
+ }
737
+ // The layout around the listing (their listing page's when ours is against another page of theirs), the empty space under it.
738
+ if (around) {
739
+ const x = compareAround(around.theirs, around.ours, around);
740
+ out.checks += x.checks;
741
+ out.weight += x.weight;
742
+ out.diffs.push(...x.diffs);
743
+ out.layout = x.layout;
744
+ }
745
+ return out;
746
+ };
747
+
748
+ /** What makes an element tall though it renders nothing: its min-height, its padding, its height (aroundOf's styles). */
749
+ export const spacingOf = (x) => {
750
+ const out = [];
751
+ if (x.minHeight && !/^(0px|auto|0)$/.test(x.minHeight)) out.push(`min-height: ${x.minHeight}`);
752
+ if (x.paddingTop) out.push(`padding-top: ${x.paddingTop}px`);
753
+ if (x.paddingBottom) out.push(`padding-bottom: ${x.paddingBottom}px`);
754
+ if (x.marginTop) out.push(`margin-top: ${x.marginTop}px`);
755
+ if (x.marginBottom) out.push(`margin-bottom: ${x.marginBottom}px`);
756
+ out.push(`height: ${round(x.height, 2)}px`);
757
+ return out.join('; ');
758
+ };
759
+
760
+ /** An empty element of theirs a theme keeps tall: what renders nothing above `EMPTY_PX` between our listing and what follows it. */
761
+ export const EMPTY_PX = 100;
762
+ /** A gap around the listing off by more than this is CRITICAL (a shopper sees a hole); within the tolerance (`collateral.gap`) alike. */
763
+ export const GAP_CRITICAL = 100;
764
+
765
+ /**
766
+ * The vertical layout around the listing (docs/PARITY.md, "The layout
767
+ * around the listing"), probe.mjs `aroundOf` on both sides:
768
+ * - `cross` (ours against another page of theirs: their listing page is the
769
+ * design source of what surrounds the listing): the gap from the bottom of
770
+ * our listing (its pager, its "show more") to the next element that
771
+ * renders something (the next block, the footer), and from what is above
772
+ * it (the heading, a toolbar) to its top, against the same gaps on their
773
+ * listing page. Off by more than `gap` px MAJOR, by more than 100 px
774
+ * CRITICAL, with the element of ours that fills the gap (an empty column
775
+ * with a theme's min-height) as the fix hint;
776
+ * - on any page: an element of theirs between our listing and what follows
777
+ * it that renders nothing yet takes more than 100 px, MAJOR, with its
778
+ * min-height, padding and height; not when their reference page has the
779
+ * same one, as tall (their own design).
780
+ * @param {ReturnType<import('./probe.mjs').aroundOf> | null} ref their listing page (cross), else their page without us
781
+ * @param {ReturnType<import('./probe.mjs').aroundOf> | null} ours
782
+ * @param {{ cross?: boolean, gap?: number, against?: string | null }} [o]
783
+ */
784
+ export const compareAround = (ref, ours, { cross = false, gap = 4, against = null } = {}) => {
785
+ const out = { checks: 0, weight: 0, diffs: /** @type {Diff[]} */ ([]), layout: { theirs: summary(ref), ours: summary(ours) } };
786
+ if (!ours) return out;
787
+ const where = against ? `their listing page (${against})` : 'their listing page';
788
+ if (cross && ref) {
789
+ for (const side of /** @type {const} */ (['above', 'below'])) {
790
+ const [t, o] = [ref[side], ours[side]];
791
+ if (!t || !o) continue;
792
+ out.checks += WEIGHT.critical;
793
+ const d = o.gap - t.gap;
794
+ if (Math.abs(d) <= gap) continue;
795
+ const severity = Math.abs(d) > GAP_CRITICAL ? 'critical' : 'major';
796
+ const weight = severity === 'critical' ? WEIGHT.critical : WEIGHT.check;
797
+ out.weight += weight;
798
+ const fill = ours.fillers?.[side]?.[0] ?? null;
799
+ const what = side === 'below' ? `from the bottom of our listing to ${name(o.path)}` : `from ${name(o.path)} to the top of our listing`;
800
+ const fix =
801
+ d > 0 && fill
802
+ ? `${name(fill.path)} fills it on our page (${spacingOf(fill)}): hide it there, or reset its min-height and padding (integration/**/style-host.css)`
803
+ : d > 0
804
+ ? `${side === 'below' ? `the margin-top of ${name(o.path)}, or our listing's margin-bottom` : `the margin-bottom of ${name(o.path)}, or our listing's margin-top`} (integration/**/*.css)`
805
+ : `their listing page leaves ${round(t.gap, 2)}px ${side === 'below' ? `before ${name(t.path)}` : `after ${name(t.path)}`}: our listing's margin-${side === 'below' ? 'bottom' : 'top'} (integration/**/*.css)`;
806
+ out.diffs.push({
807
+ part: 'listing',
808
+ prop: `gap-${side}`,
809
+ theirs: round(t.gap, 2),
810
+ ours: round(o.gap, 2),
811
+ delta: `${d > 0 ? '+' : ''}${round(d, 2)}px ${what} (${round(t.gap, 2)}px on ${where})`,
812
+ severity,
813
+ group: 'collateral',
814
+ weight,
815
+ hint: `the space ${what} is ${round(o.gap, 2)}px, ${round(t.gap, 2)}px on ${where}: ${fix}`,
816
+ ...(fill ? { responsible: { path: name(fill.path), spacing: spacingOf(fill) } } : {}),
817
+ });
818
+ }
819
+ }
820
+ for (const e of ours.empty ?? []) {
821
+ out.checks += WEIGHT.check;
822
+ // Their reference page has it too, as tall: their design, not ours to fix.
823
+ if ((ref?.empty ?? []).some((x) => x.path === e.path && Math.abs(x.height - e.height) <= gap)) continue;
824
+ out.weight += WEIGHT.check;
825
+ out.diffs.push({
826
+ part: name(e.path),
827
+ prop: 'empty-space',
828
+ theirs: 'none',
829
+ ours: `${round(e.height, 2)}px`,
830
+ delta: `renders nothing, yet takes ${round(e.height, 2)}px between our listing and what follows it`,
831
+ severity: 'major',
832
+ group: 'collateral',
833
+ weight: WEIGHT.check,
834
+ hint: `${name(e.path)} renders nothing on our page, yet takes ${round(e.height, 2)}px under our listing (${spacingOf(e)}): hide it on our page, or reset its min-height and padding (integration/**/style-host.css)`,
835
+ });
836
+ }
837
+ return out;
838
+ };
839
+ const name = (path) => String(path ?? '').replace(/^body > /, '');
840
+ /** The layout around the listing, for the report: the listing's box, the gaps above and below and what bounds them. */
841
+ const summary = (x) =>
842
+ x ? { listing: x.listing, above: x.above ? { gap: round(x.above.gap, 2), by: name(x.above.path) } : null, below: x.below ? { gap: round(x.below.gap, 2), by: name(x.below.path) } : null, empty: (x.empty ?? []).map((e) => ({ path: name(e.path), height: round(e.height, 2) })) } : null;
843
+
844
+ /**
845
+ * The collateral check as a result (compareComponent's shape): component
846
+ * `collateral` (`collateral@<state>` in a state), one instance, the
847
+ * differences the template's (decided ones aside).
848
+ * @param {{ page: string, viewport: number, state?: string | null }} at @param {ReturnType<typeof compareCollateral>} r @param {{ decided?: Record<string, any> }} [o]
849
+ */
850
+ export const collateralResult = (at, r, { decided = {} } = {}) => {
851
+ const component = at.state ? `collateral@${at.state}` : 'collateral';
852
+ const diffs = decide(r.diffs.map((d) => ({ ...d, owner: 'template' })), { component: 'collateral', decided });
853
+ const templateWeight = diffs.filter((d) => d.owner === 'template').reduce((s, d) => s + d.weight, 0);
854
+ const score = scoreOf(r);
855
+ const templateScore = scoreOf({ checks: r.checks, weight: templateWeight });
856
+ return { component, page: at.page, viewport: at.viewport, state: at.state ?? null, score, templateScore, instances: r.checks ? [{ key: 'page', score, templateScore, checks: r.checks, weight: r.weight, pixels: null }] : [], diffs: aggregate([diffs]), ...(r.layout ? { layout: r.layout } : {}) };
857
+ };
858
+
859
+ /**
860
+ * Their products never paired with ours on a page (pairing.mjs
861
+ * `pairingFailure`): a CRITICAL result scoring 0, never a silent fallback to
862
+ * structure only. The verdict names it first.
863
+ * @param {{ page: string, viewport: number }} at @param {{ why: string, queries: any[] }} failure @param {number} asked their products
864
+ */
865
+ export const pairingResult = (at, failure, asked) => ({
866
+ component: 'pairing',
867
+ page: at.page,
868
+ viewport: at.viewport,
869
+ state: null,
870
+ score: 0,
871
+ templateScore: 0,
872
+ instances: [{ key: 'pairing', score: 0, templateScore: 0, checks: WEIGHT.critical, weight: WEIGHT.critical, pixels: null }],
873
+ diffs: [{ part: 'root', prop: 'pairing', theirs: `${asked} products`, ours: '0 paired', delta: failure.why, severity: 'critical', group: 'pairing', weight: WEIGHT.critical, owner: 'template', count: 1, queries: failure.queries }],
874
+ });
875
+
876
+ /**
877
+ * A state's steps that could not be taken (a click target missing on ours:
878
+ * the drawer toggle our template lacks): CRITICAL, one per step and side.
879
+ * The state's components are measured as far as it got.
880
+ * @param {{ page: string, viewport: number, state: string }} at @param {{ step: number, kind: string, side: 'theirs' | 'ours', selector: string | null, error: string }[]} failed
881
+ */
882
+ export const stateResult = (at, failed) => {
883
+ const diffs = failed.map((f) => ({ part: `step ${f.step}`, prop: 'state', theirs: f.side === 'theirs' ? `failed: ${f.error}` : 'taken', ours: f.side === 'ours' ? `failed: ${f.error}` : 'taken', delta: `${f.kind} ${f.selector ?? ''} failed on ${f.side}`.replace(/\s+/g, ' '), severity: 'critical', group: 'state', weight: WEIGHT.critical, owner: 'template', count: 1, selector: { theirs: f.side === 'theirs' ? f.selector : null, ours: f.side === 'ours' ? f.selector : null } }));
884
+ const checks = Math.max(1, failed.length) * WEIGHT.critical;
885
+ const weight = failed.length * WEIGHT.critical;
886
+ const score = scoreOf({ checks, weight });
887
+ return { component: `state@${at.state}`, page: at.page, viewport: at.viewport, state: at.state, score, templateScore: score, instances: [{ key: at.state, score, templateScore: score, checks, weight, pixels: null }], diffs };
888
+ };
889
+
890
+ /**
891
+ * The template's differences of the whole run, the same one at several
892
+ * viewports one finding: the list an agent fixes from, ranked by weight
893
+ * (CRITICAL and MAJOR before MINOR, then the weight they take off the score:
894
+ * the severity's weight times the instances and viewports it shows on).
895
+ * `owner: 'data'` gives the data's differences instead (for the client).
896
+ * @param {ReturnType<typeof compareComponent>[]} results
897
+ * @param {'template' | 'data'} [owner]
898
+ */
899
+ export const findings = (results, owner = 'template') => {
900
+ const by = new Map();
901
+ for (const r of results)
902
+ for (const d of r.diffs ?? []) {
903
+ if ((d.owner ?? 'template') !== owner) continue;
904
+ const key = JSON.stringify([r.component, r.page, d.part, d.prop, d.prop === 'pixels' ? '' : d.theirs, d.prop === 'pixels' ? '' : d.ours]);
905
+ let f = by.get(key);
906
+ if (!f) {
907
+ const { count: _c, ratio: _r, ...rest } = d;
908
+ f = { component: r.component, page: r.page, ...rest, viewports: [], count: 0, total: 0, instances: 0 };
909
+ by.set(key, f);
910
+ }
911
+ if (!f.viewports.includes(r.viewport)) f.viewports.push(r.viewport);
912
+ f.count += d.count ?? 1;
913
+ f.instances += r.instances.length || 1;
914
+ f.total += (d.weight ?? WEIGHT.check) * (d.count ?? 1);
915
+ if (SEVERITY_RANK[d.severity] < SEVERITY_RANK[f.severity]) f.severity = d.severity;
916
+ if (r.selectors && !f.selector) f.selector = selectorOf(r.selectors, d.part);
917
+ }
918
+ // What follows `color` (currentColor: the borders, the outline, the decoration) with the same
919
+ // values is one fix: folded into the colour's finding (`also`).
920
+ for (const [k, f] of by) {
921
+ if (!FOLLOWS_COLOR.has(f.prop)) continue;
922
+ const c = by.get(JSON.stringify([f.component, f.page, f.part, 'color', f.theirs, f.ours]));
923
+ if (!c) continue;
924
+ (c.also ??= []).push(f.prop);
925
+ by.delete(k);
926
+ }
927
+ // A failed pairing first: until it pairs, every product's comparison is by structure only.
928
+ const blocking = (f) => (f.prop === 'pairing' ? -1 : f.severity === 'minor' ? 1 : 0);
929
+ return [...by.values()]
930
+ .map((f) => ({ ...f, viewports: f.viewports.sort((a, b) => a - b) }))
931
+ .sort((x, y) => blocking(x) - blocking(y) || y.total - x.total || SEVERITY_RANK[x.severity] - SEVERITY_RANK[y.severity] || (x.component < y.component ? -1 : x.component > y.component ? 1 : 0) || (x.part < y.part ? -1 : x.part > y.part ? 1 : 0) || (x.prop < y.prop ? -1 : x.prop > y.prop ? 1 : 0));
932
+ };
933
+
934
+ /** A part's selectors on both sides, page-level: the root's, then the part's within it. @param {{ theirs: string, ours: string, parts: Record<string, { theirs: string | null, ours: string | null }> }} s @param {string} part */
935
+ export const selectorOf = (s, part) => {
936
+ if (part === 'root' || part === 'grid' || !s.parts?.[part]) return { theirs: s.theirs, ours: s.ours, role: s.role ?? null };
937
+ const p = s.parts[part];
938
+ return { theirs: p.theirs ? `${s.theirs} ${p.theirs}` : null, ours: p.ours ? `${s.ours} ${p.ours}` : null, part: { theirs: p.theirs, ours: p.ours }, role: p.role ?? null };
939
+ };
940
+
941
+ /**
942
+ * The verdict: the overall score (every difference, the data's too) and the
943
+ * template's score (the means over components and viewports), and whether
944
+ * the port has converged: the template's score at or over `target`, no
945
+ * CRITICAL or MAJOR template difference left, every mandatory component
946
+ * mapped or excluded (`coverage`, map.mjs), no component's template score
947
+ * under its threshold (`thresholds.components[name]`, else
948
+ * `thresholds.score`). Exit code 0 converged, 1 not, 2 nothing measured.
949
+ * @param {ReturnType<typeof compareComponent>[]} results
950
+ * @param {{ score?: number, components?: Record<string, number> }} thresholds
951
+ * @param {{ target?: number, coverage?: { ok: boolean, missing: string[] } | null }} [opts]
952
+ */
953
+ export const verdict = (results, thresholds = {}, { target = TARGET, coverage = null } = {}) => {
954
+ const mean = (xs) => (xs.length ? round(xs.reduce((s, x) => s + x, 0) / xs.length, 1) : null);
955
+ const scored = results.filter((r) => r.score != null);
956
+ const overall = mean(scored.map((r) => r.score));
957
+ const templateScore = mean(scored.map((r) => r.templateScore ?? r.score).filter((s) => s != null));
958
+ const failing = scored
959
+ .map((r) => ({ component: r.component, page: r.page, viewport: r.viewport, score: r.templateScore ?? r.score, threshold: thresholds.components?.[baseOf(r.component)] ?? thresholds.score ?? 0 }))
960
+ .filter((r) => r.score < r.threshold);
961
+ const todo = findings(results);
962
+ const blocking = todo.filter((f) => f.severity !== 'minor');
963
+ const reasons = [];
964
+ // Their products never paired with ours: everything per product was compared by structure only. Loud, first.
965
+ for (const r of results.filter((x) => x.component === 'pairing')) reasons.push(`pairing failed on ${r.page} at ${r.viewport}px: ${r.diffs[0]?.delta ?? ''}`);
966
+ if (templateScore != null && templateScore < target) reasons.push(`the template score ${templateScore} is under the target ${target}`);
967
+ if (blocking.length) reasons.push(`${blocking.length} CRITICAL or MAJOR template difference${blocking.length > 1 ? 's' : ''} left`);
968
+ if (coverage && !coverage.ok) reasons.push(`mandatory components neither mapped nor excluded: ${coverage.missing.join(', ')}`);
969
+ if (failing.length) reasons.push(`${failing.length} under its threshold: ${failing.slice(0, 6).map((f) => `${f.component}@${f.viewport} ${f.score} < ${f.threshold}`).join(', ')}${failing.length > 6 ? ', …' : ''}`);
970
+ const converged = templateScore != null && !reasons.length;
971
+ return {
972
+ overall,
973
+ templateScore,
974
+ target,
975
+ blocking: blocking.length,
976
+ critical: blocking.filter((f) => f.severity === 'critical').length,
977
+ coverage: coverage ? { ok: coverage.ok, missing: coverage.missing } : null,
978
+ failing,
979
+ converged,
980
+ reasons: templateScore == null ? ['nothing was measured'] : reasons,
981
+ exitCode: templateScore == null ? 2 : converged ? 0 : 1,
982
+ };
983
+ };
984
+ /** The default `--target`: the template score a converged port reaches. */
985
+ export const TARGET = 98;