@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.
- package/LICENSE.md +90 -0
- package/README.md +615 -2
- package/a11y/audit.mjs +735 -0
- package/a11y/axe.mjs +73 -0
- package/a11y/report.mjs +347 -0
- package/a11y/run.mjs +256 -0
- package/a11y/wcag.mjs +233 -0
- package/bin/config.mjs +60 -0
- package/bin/luma.mjs +99 -0
- package/bin/paths.mjs +11 -0
- package/bin/tools.mjs +43 -0
- package/build/baseline.d.mts +8 -0
- package/build/baseline.mjs +167 -0
- package/build/tooling.mjs +102 -0
- package/build/vite.mjs +507 -0
- package/chrome/block.mjs +8 -0
- package/chrome/run.mjs +150 -0
- package/docs/ACCESSIBILITY.md +501 -0
- package/docs/INTEGRATION-PERFORMANCE.md +213 -0
- package/docs/PARITY.md +946 -0
- package/footprint/model.mjs +184 -0
- package/footprint/run.mjs +331 -0
- package/package.json +58 -4
- package/parity/alignment.mjs +109 -0
- package/parity/archive.mjs +223 -0
- package/parity/args.mjs +137 -0
- package/parity/behaviour.mjs +379 -0
- package/parity/compare.mjs +985 -0
- package/parity/engine.mjs +848 -0
- package/parity/hints.mjs +132 -0
- package/parity/hold.mjs +54 -0
- package/parity/init.mjs +703 -0
- package/parity/map.mjs +640 -0
- package/parity/owner.mjs +170 -0
- package/parity/pairing.mjs +110 -0
- package/parity/pixels.mjs +33 -0
- package/parity/probe.mjs +1239 -0
- package/parity/report.mjs +236 -0
- package/parity/run.mjs +267 -0
- package/snippet/early-hold.js +64 -0
- package/snippet/early-hold.min.js +1 -0
- package/src/analytics/chain.ts +137 -0
- package/src/analytics/collect.ts +64 -0
- package/src/analytics/datalayer.ts +67 -0
- package/src/analytics/from-ga4.ts +68 -0
- package/src/analytics/from-luma.ts +21 -0
- package/src/analytics/index.ts +24 -0
- package/src/analytics/outbox.ts +118 -0
- package/src/analytics/page.ts +46 -0
- package/src/analytics/pageview.ts +82 -0
- package/src/analytics/ref.ts +19 -0
- package/src/analytics/setup.ts +41 -0
- package/src/analytics/start.ts +53 -0
- package/src/analytics/triggers.ts +239 -0
- package/src/analytics/user.ts +12 -0
- package/src/analytics/wire.ts +67 -0
- package/src/api/ai.ts +98 -0
- package/src/api/index.ts +87 -0
- package/src/api/token-key.ts +42 -0
- package/src/api/token.ts +312 -0
- package/src/core/a11y-keys.ts +60 -0
- package/src/core/a11y.ts +160 -0
- package/src/core/boot-state.ts +6 -0
- package/src/core/color.ts +42 -0
- package/src/core/context.ts +108 -0
- package/src/core/early-rules.ts +96 -0
- package/src/core/entries.ts +25 -0
- package/src/core/fold.ts +7 -0
- package/src/core/health.ts +26 -0
- package/src/core/hold.ts +74 -0
- package/src/core/i18n.ts +66 -0
- package/src/core/image-load.ts +102 -0
- package/src/core/image-tags.ts +26 -0
- package/src/core/images.ts +170 -0
- package/src/core/integration.ts +126 -0
- package/src/core/kept.ts +25 -0
- package/src/core/listings.ts +100 -0
- package/src/core/locales.ts +14 -0
- package/src/core/own.ts +49 -0
- package/src/core/page.ts +14 -0
- package/src/core/prefetch.ts +88 -0
- package/src/core/recommenders.ts +166 -0
- package/src/core/reconciler.ts +397 -0
- package/src/core/result.ts +73 -0
- package/src/core/routes.ts +115 -0
- package/src/core/scheduler.ts +83 -0
- package/src/core/sense.ts +107 -0
- package/src/core/speculation.ts +59 -0
- package/src/core/stash.ts +38 -0
- package/src/core/storage.ts +45 -0
- package/src/core/styles.ts +106 -0
- package/src/core/support.ts +12 -0
- package/src/core/takeover.ts +220 -0
- package/src/core/templates.ts +74 -0
- package/src/core/tiles.ts +137 -0
- package/src/core/turbo.ts +64 -0
- package/src/core/url.ts +164 -0
- package/src/core/widget-inits.ts +40 -0
- package/src/env.d.ts +3 -0
- package/src/lit.ts +11 -0
- package/src/locales/cs.json +94 -0
- package/src/locales/de.json +88 -0
- package/src/locales/en.json +88 -0
- package/src/main.ts +493 -0
- package/src/types.ts +778 -0
- package/src/widgets/autocomplete/index.ts +1387 -0
- package/src/widgets/autocomplete/style-autocomplete.css +266 -0
- package/src/widgets/autocomplete/template-autocomplete-box.ts +61 -0
- package/src/widgets/autocomplete/template-autocomplete-empty.ts +53 -0
- package/src/widgets/autocomplete/template-autocomplete-result-default.ts +82 -0
- package/src/widgets/autocomplete/template-autocomplete-sheet.ts +74 -0
- package/src/widgets/listing/element.ts +1772 -0
- package/src/widgets/listing/flow.ts +30 -0
- package/src/widgets/listing/instant.ts +133 -0
- package/src/widgets/listing/intent.ts +193 -0
- package/src/widgets/listing/query.ts +298 -0
- package/src/widgets/listing/stash.ts +97 -0
- package/src/widgets/listing/state.ts +131 -0
- package/src/widgets/listing/view.ts +257 -0
- package/src/widgets/plp/index.ts +11 -0
- package/src/widgets/plp/route.ts +263 -0
- package/src/widgets/recommender/index.ts +261 -0
- package/src/widgets/recommender/style-recommender.css +26 -0
- package/src/widgets/recommender/template-recommender-box-default.ts +11 -0
- package/src/widgets/recommender/template-recommender-popup.ts +12 -0
- package/src/widgets/recommender/template-recommender-result-default.ts +10 -0
- package/src/widgets/search/assets.ts +7 -0
- package/src/widgets/search/first.ts +15 -0
- package/src/widgets/search/form-url.ts +59 -0
- package/src/widgets/search/in-place.ts +226 -0
- package/src/widgets/search/index.ts +16 -0
- package/src/widgets/search/range.ts +260 -0
- package/src/widgets/search/route.ts +32 -0
- package/src/widgets/search/speculate.ts +202 -0
- package/src/widgets/search/style-search.css +65 -0
- package/src/widgets/search/template-search-box.ts +17 -0
- package/src/widgets/search/template-search-facets-default.ts +73 -0
- package/src/widgets/search/template-search-header.ts +14 -0
- package/src/widgets/search/template-search-pagination.ts +12 -0
- package/src/widgets/search/template-search-range-default.ts +48 -0
- package/src/widgets/search/template-search-result-default.ts +13 -0
- package/src/widgets/search/template-search-results-default.ts +8 -0
- package/tasks/model.mjs +116 -0
- package/tasks/run.mjs +305 -0
- 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;
|