@tractiontactics/tt-fidelity 0.2.1
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/AGENTS.md +41 -0
- package/README.md +150 -0
- package/bin/tt-fidelity.js +2 -0
- package/fidelity-diff.mjs +7 -0
- package/fidelity.schema.json +77 -0
- package/fixtures/draft-opaque.html +57 -0
- package/fixtures/draft.html +61 -0
- package/fixtures/pages/cluster-a-draft.html +19 -0
- package/fixtures/pages/cluster-a-proto.html +19 -0
- package/fixtures/pages/cluster-b-draft.html +19 -0
- package/fixtures/pages/cluster-b-proto.html +19 -0
- package/fixtures/pages/cluster-pages.json +12 -0
- package/fixtures/pixel-diverge.html +28 -0
- package/fixtures/pixel-identical.html +21 -0
- package/fixtures/proto.html +51 -0
- package/fixtures/roles-opaque.json +13 -0
- package/fixtures/self-test.mjs +111 -0
- package/package.json +55 -0
- package/src/capture.mjs +114 -0
- package/src/cli.mjs +124 -0
- package/src/constants.mjs +73 -0
- package/src/fonts.mjs +61 -0
- package/src/index.mjs +6 -0
- package/src/pixel.mjs +197 -0
- package/src/plan.mjs +410 -0
- package/src/rank.mjs +52 -0
- package/src/run.mjs +433 -0
- package/src/schema.mjs +62 -0
- package/src/sections.mjs +119 -0
- package/src/styles.mjs +356 -0
- package/src/tt-map.mjs +17 -0
package/src/styles.mjs
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { ALL_PROPS, BUCKET_OF, CHROME_ROLES, DEFAULT_ROLES } from './constants.mjs';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
|
|
4
|
+
export function loadRoles(path) {
|
|
5
|
+
if (!path) return DEFAULT_ROLES;
|
|
6
|
+
let parsed;
|
|
7
|
+
try {
|
|
8
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
9
|
+
} catch (err) {
|
|
10
|
+
throw new Error(`could not read --roles file "${path}": ${err.message}`);
|
|
11
|
+
}
|
|
12
|
+
if (!Array.isArray(parsed) || !parsed.length) {
|
|
13
|
+
throw new Error('--roles file must be a non-empty JSON array of [role, selectors[], max]');
|
|
14
|
+
}
|
|
15
|
+
for (const entry of parsed) {
|
|
16
|
+
if (!Array.isArray(entry) || typeof entry[0] !== 'string' || !Array.isArray(entry[1])) {
|
|
17
|
+
throw new Error('each --roles entry must be [role: string, selectors: string[], max?: number]');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return parsed.map(([role, sels, max]) => [role, sels, Number(max) > 0 ? Number(max) : 1]);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function filterRolesByScope(roles, scope) {
|
|
24
|
+
const s = String(scope || 'full').toLowerCase();
|
|
25
|
+
if ('full' === s) return roles;
|
|
26
|
+
if ('chrome' === s) {
|
|
27
|
+
return roles.filter(([role]) => CHROME_ROLES.has(role));
|
|
28
|
+
}
|
|
29
|
+
if ('content' === s) {
|
|
30
|
+
return roles.filter(([role]) => !CHROME_ROLES.has(role));
|
|
31
|
+
}
|
|
32
|
+
throw new Error(`unknown --scope "${scope}" (use full, chrome, or content)`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* eslint-disable no-undef */
|
|
36
|
+
export function inPageCapture({ props, roles }) {
|
|
37
|
+
const pick = (sels) => {
|
|
38
|
+
for (const s of sels) {
|
|
39
|
+
let n;
|
|
40
|
+
try {
|
|
41
|
+
n = document.querySelectorAll(s);
|
|
42
|
+
} catch (e) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (n.length) return [...n];
|
|
46
|
+
}
|
|
47
|
+
return [];
|
|
48
|
+
};
|
|
49
|
+
const visible = (el) => {
|
|
50
|
+
if (!el) return false;
|
|
51
|
+
const cs = getComputedStyle(el);
|
|
52
|
+
if (cs.visibility === 'hidden' || cs.display === 'none') return false;
|
|
53
|
+
if (el.offsetParent === null && cs.position !== 'fixed') return false;
|
|
54
|
+
const r = el.getBoundingClientRect();
|
|
55
|
+
return r.width > 0 || r.height > 0;
|
|
56
|
+
};
|
|
57
|
+
const sel = (el) => {
|
|
58
|
+
if (!el || el === document.body) return 'body';
|
|
59
|
+
if (el.id) return `#${el.id}`;
|
|
60
|
+
const parts = [];
|
|
61
|
+
let c = el;
|
|
62
|
+
let i = 0;
|
|
63
|
+
while (c && c.nodeType === 1 && i < 4) {
|
|
64
|
+
let n = c.tagName.toLowerCase();
|
|
65
|
+
if (c.classList && c.classList.length) n += `.${[...c.classList].slice(0, 2).join('.')}`;
|
|
66
|
+
parts.unshift(n);
|
|
67
|
+
c = c.parentElement;
|
|
68
|
+
i += 1;
|
|
69
|
+
}
|
|
70
|
+
return parts.join(' > ');
|
|
71
|
+
};
|
|
72
|
+
const label = (el) => (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 48);
|
|
73
|
+
const read = (el) => {
|
|
74
|
+
const cs = getComputedStyle(el);
|
|
75
|
+
const r = el.getBoundingClientRect();
|
|
76
|
+
const o = {};
|
|
77
|
+
for (const p of props) {
|
|
78
|
+
const v = cs.getPropertyValue(p);
|
|
79
|
+
if (v) o[p] = String(v).trim();
|
|
80
|
+
}
|
|
81
|
+
o._w = `${Math.round(r.width)}px`;
|
|
82
|
+
o._h = `${Math.round(r.height)}px`;
|
|
83
|
+
o._x = `${Math.round(r.left)}px`;
|
|
84
|
+
return o;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const nodes = [];
|
|
88
|
+
const counts = {};
|
|
89
|
+
for (const [role, sels, max] of roles) {
|
|
90
|
+
const got = pick(sels).filter(visible);
|
|
91
|
+
counts[role] = got.length;
|
|
92
|
+
got.slice(0, max).forEach((el, i) => {
|
|
93
|
+
nodes.push({
|
|
94
|
+
key: max > 1 ? `${role}:${i + 1}` : role,
|
|
95
|
+
role,
|
|
96
|
+
selector: sel(el),
|
|
97
|
+
label: label(el),
|
|
98
|
+
styles: read(el)
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return { url: location.href, title: document.title, viewport: document.documentElement.clientWidth, counts, nodes };
|
|
103
|
+
}
|
|
104
|
+
/* eslint-enable no-undef */
|
|
105
|
+
|
|
106
|
+
export { ALL_PROPS };
|
|
107
|
+
|
|
108
|
+
const pxOf = (v) => {
|
|
109
|
+
const m = /^(-?[\d.]+)px$/.exec(String(v ?? '').trim());
|
|
110
|
+
return m ? parseFloat(m[1]) : null;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
function normColour(v) {
|
|
114
|
+
const s = String(v ?? '').trim().toLowerCase();
|
|
115
|
+
if (!s) return '';
|
|
116
|
+
if (s === 'transparent' || /^rgba\(\s*0,\s*0,\s*0,\s*0\s*\)$/.test(s.replace(/\s+/g, ' '))) return 'transparent';
|
|
117
|
+
const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/.exec(s);
|
|
118
|
+
if (hex) {
|
|
119
|
+
let h = hex[1];
|
|
120
|
+
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
|
|
121
|
+
return `rgb(${parseInt(h.slice(0, 2), 16)}, ${parseInt(h.slice(2, 4), 16)}, ${parseInt(h.slice(4, 6), 16)})`;
|
|
122
|
+
}
|
|
123
|
+
const m = /^rgba\(([^)]+),\s*1\)$/.exec(s.replace(/\s+/g, ' '));
|
|
124
|
+
if (m) return `rgb(${m[1].trim()})`;
|
|
125
|
+
return s.replace(/\s+/g, ' ');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function norm(prop, raw) {
|
|
129
|
+
const v = String(raw ?? '').trim();
|
|
130
|
+
if (!v) return '';
|
|
131
|
+
if (prop === 'color' || /color$/.test(prop)) return normColour(v);
|
|
132
|
+
if (prop === 'font-family') return v.split(',')[0].replace(/["']/g, '').trim().toLowerCase();
|
|
133
|
+
if (prop === 'transition-property' || prop === 'animation-name') return v.toLowerCase().replace(/\s+/g, '');
|
|
134
|
+
return v.replace(/\s+/g, ' ');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function matches(prop, a, b, tol) {
|
|
138
|
+
const na = norm(prop, a);
|
|
139
|
+
const nb = norm(prop, b);
|
|
140
|
+
if (na === nb) return true;
|
|
141
|
+
const pa = pxOf(na);
|
|
142
|
+
const pb = pxOf(nb);
|
|
143
|
+
if (pa !== null && pb !== null) return Math.abs(pa - pb) <= tol;
|
|
144
|
+
if (prop === 'opacity') {
|
|
145
|
+
const fa = parseFloat(na);
|
|
146
|
+
const fb = parseFloat(nb);
|
|
147
|
+
if (!Number.isNaN(fa) && !Number.isNaN(fb)) return Math.abs(fa - fb) <= 0.01;
|
|
148
|
+
}
|
|
149
|
+
if (prop === 'line-height' && (na === 'normal' || nb === 'normal')) return true;
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function mergeGapRows(rows) {
|
|
154
|
+
const r = rows.filter((x) => x.prop === 'row-gap');
|
|
155
|
+
const c = rows.filter((x) => x.prop === 'column-gap');
|
|
156
|
+
if (r.length !== 1 || c.length !== 1) return rows;
|
|
157
|
+
if (r[0].proto !== c[0].proto || r[0].draft !== c[0].draft) return rows;
|
|
158
|
+
return [
|
|
159
|
+
{ ...r[0], prop: 'gap' },
|
|
160
|
+
...rows.filter((x) => x.prop !== 'row-gap' && x.prop !== 'column-gap')
|
|
161
|
+
];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function collapseGlobalRows(elements, minShared) {
|
|
165
|
+
const tally = new Map();
|
|
166
|
+
for (const el of elements) {
|
|
167
|
+
for (const row of el.rows) {
|
|
168
|
+
if (row.geometry) continue;
|
|
169
|
+
const k = `${row.prop}\u0000${row.proto}\u0000${row.draft}`;
|
|
170
|
+
if (!tally.has(k)) tally.set(k, { row, keys: [] });
|
|
171
|
+
tally.get(k).keys.push(el.key);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const global = [];
|
|
176
|
+
const lifted = new Set();
|
|
177
|
+
for (const [k, { row, keys }] of tally) {
|
|
178
|
+
if (keys.length < minShared) continue;
|
|
179
|
+
lifted.add(k);
|
|
180
|
+
global.push({
|
|
181
|
+
prop: row.prop,
|
|
182
|
+
proto: row.proto,
|
|
183
|
+
draft: row.draft,
|
|
184
|
+
bucket: row.bucket,
|
|
185
|
+
count: keys.length,
|
|
186
|
+
examples: keys.slice(0, 5)
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (!lifted.size) return { elements, global };
|
|
191
|
+
|
|
192
|
+
const trimmed = [];
|
|
193
|
+
for (const el of elements) {
|
|
194
|
+
const rows = el.rows.filter(
|
|
195
|
+
(row) => row.geometry || !lifted.has(`${row.prop}\u0000${row.proto}\u0000${row.draft}`)
|
|
196
|
+
);
|
|
197
|
+
if (rows.length) trimmed.push({ ...el, rows });
|
|
198
|
+
}
|
|
199
|
+
return { elements: trimmed, global };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function diffStyles(proto, draft, tol, only) {
|
|
203
|
+
const keep = (bucket) => !only || only.includes(bucket);
|
|
204
|
+
const idx = (l) => Object.fromEntries(l.map((n) => [n.key, n]));
|
|
205
|
+
const A = idx(proto.nodes);
|
|
206
|
+
const B = idx(draft.nodes);
|
|
207
|
+
const elements = [];
|
|
208
|
+
const structural = [];
|
|
209
|
+
const leftEdgeSeen = new Set();
|
|
210
|
+
|
|
211
|
+
for (const [key, a] of Object.entries(A)) {
|
|
212
|
+
const b = B[key];
|
|
213
|
+
if (!b) {
|
|
214
|
+
structural.push({
|
|
215
|
+
kind: 'unmatched',
|
|
216
|
+
role: a.role,
|
|
217
|
+
text: `${key}${a.label ? ` ("${a.label}")` : ''} matched on the prototype but not on the draft — either genuinely absent, or the selectors need extending for this build (prototype: ${a.selector})`
|
|
218
|
+
});
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const rows = [];
|
|
222
|
+
for (const [prop, av] of Object.entries(a.styles)) {
|
|
223
|
+
if (prop.startsWith('_')) continue;
|
|
224
|
+
const bv = b.styles[prop];
|
|
225
|
+
if (bv === undefined) continue;
|
|
226
|
+
if (matches(prop, av, bv, tol)) continue;
|
|
227
|
+
const bucket = CHROME_ROLES.has(a.role) ? 'menus' : BUCKET_OF[prop] || 'flow';
|
|
228
|
+
if (!keep(bucket)) continue;
|
|
229
|
+
rows.push({ prop, proto: norm(prop, av), draft: norm(prop, bv), bucket });
|
|
230
|
+
}
|
|
231
|
+
for (const [g, name] of [['_w', 'rendered width'], ['_h', 'rendered height'], ['_x', 'left edge']]) {
|
|
232
|
+
const av = a.styles[g];
|
|
233
|
+
const bv = b.styles[g];
|
|
234
|
+
if (av === undefined || bv === undefined) continue;
|
|
235
|
+
if (matches('width', av, bv, Math.max(tol, 2))) continue;
|
|
236
|
+
if (g === '_x') {
|
|
237
|
+
if (leftEdgeSeen.has(a.role)) continue;
|
|
238
|
+
leftEdgeSeen.add(a.role);
|
|
239
|
+
}
|
|
240
|
+
const bucket = g === '_x' ? 'alignment' : 'widths';
|
|
241
|
+
if (!keep(CHROME_ROLES.has(a.role) ? 'menus' : bucket)) continue;
|
|
242
|
+
rows.push({ prop: name, proto: av, draft: bv, bucket, geometry: true });
|
|
243
|
+
}
|
|
244
|
+
if (rows.length) {
|
|
245
|
+
elements.push({
|
|
246
|
+
key,
|
|
247
|
+
role: a.role,
|
|
248
|
+
label: a.label,
|
|
249
|
+
protoSelector: a.selector,
|
|
250
|
+
draftSelector: b.selector,
|
|
251
|
+
rows: mergeGapRows(rows)
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
for (const [key, b] of Object.entries(B)) {
|
|
257
|
+
if (A[key]) continue;
|
|
258
|
+
structural.push({
|
|
259
|
+
kind: 'extra',
|
|
260
|
+
role: b.role,
|
|
261
|
+
text: `${key}${b.label ? ` ("${b.label}")` : ''} matched on the draft but not on the prototype — either an addition the prototype lacks, or a selector mismatch (draft: ${b.selector})`
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
for (const [role, ca] of Object.entries(proto.counts)) {
|
|
266
|
+
const cb = draft.counts[role];
|
|
267
|
+
if (typeof cb !== 'number' || ca === cb) continue;
|
|
268
|
+
structural.push({
|
|
269
|
+
kind: 'count',
|
|
270
|
+
role,
|
|
271
|
+
text: `${role}: prototype has ${ca}, draft has ${cb}${cb < ca ? ` (draft is missing ${ca - cb})` : ` (draft has ${cb - ca} extra)`}`
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const { elements: trimmed, global } = collapseGlobalRows(elements, 3);
|
|
276
|
+
const total = global.length + trimmed.reduce((n, el) => n + el.rows.length, 0);
|
|
277
|
+
|
|
278
|
+
return { elements: trimmed, global, structural, total };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function assessCoverage(proto, draft, roles) {
|
|
282
|
+
const matched = [];
|
|
283
|
+
const onlyProto = [];
|
|
284
|
+
const onlyDraft = [];
|
|
285
|
+
const absentBoth = [];
|
|
286
|
+
|
|
287
|
+
for (const [role] of roles) {
|
|
288
|
+
const a = (proto.counts[role] || 0) > 0;
|
|
289
|
+
const b = (draft.counts[role] || 0) > 0;
|
|
290
|
+
if (a && b) matched.push(role);
|
|
291
|
+
else if (a) onlyProto.push(role);
|
|
292
|
+
else if (b) onlyDraft.push(role);
|
|
293
|
+
else absentBoth.push(role);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const comparable = matched.length + onlyProto.length + onlyDraft.length;
|
|
297
|
+
const ratio = comparable ? matched.length / comparable : 0;
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
matched,
|
|
301
|
+
onlyProto,
|
|
302
|
+
onlyDraft,
|
|
303
|
+
absentBoth,
|
|
304
|
+
ratio,
|
|
305
|
+
unreliable: ratio < 0.5 && onlyProto.length + onlyDraft.length >= 3
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function renderCoverage(cov) {
|
|
310
|
+
const pct = Math.round(cov.ratio * 100);
|
|
311
|
+
const out = [
|
|
312
|
+
`COVERAGE: ${cov.matched.length}/${cov.matched.length + cov.onlyProto.length + cov.onlyDraft.length} roles matched on both pages (${pct}%).`
|
|
313
|
+
];
|
|
314
|
+
if (cov.onlyProto.length) out.push(` matched on the prototype only: ${cov.onlyProto.join(', ')}`);
|
|
315
|
+
if (cov.onlyDraft.length) out.push(` matched on the draft only: ${cov.onlyDraft.join(', ')}`);
|
|
316
|
+
if (cov.absentBoth.length) out.push(` absent from both (not applicable): ${cov.absentBoth.join(', ')}`);
|
|
317
|
+
return out;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function renderCoverageFailure(cov, rolesPath) {
|
|
321
|
+
return [
|
|
322
|
+
'CANNOT MEASURE RELIABLY — ABORTING RATHER THAN GUESSING',
|
|
323
|
+
'',
|
|
324
|
+
...renderCoverage(cov),
|
|
325
|
+
'',
|
|
326
|
+
'Most roles matched on only one side. That almost always means the selector',
|
|
327
|
+
'list does not fit one of these builds — not that the elements are missing.',
|
|
328
|
+
'Reporting them as "missing" would produce a work plan telling you to rebuild',
|
|
329
|
+
'parts of a page that are already there, so no plan has been produced.',
|
|
330
|
+
'',
|
|
331
|
+
'Fix by supplying a role map for this project:',
|
|
332
|
+
'',
|
|
333
|
+
' 1. Inspect the unmatched side and find the real selectors.',
|
|
334
|
+
' 2. Write a JSON file, e.g. roles.json:',
|
|
335
|
+
'',
|
|
336
|
+
' [',
|
|
337
|
+
' ["header", [".Hdr_a1b2", "header"], 1],',
|
|
338
|
+
' ["nav", [".Nav_c3d4"], 1],',
|
|
339
|
+
' ["nav-item", [".Nav_c3d4 a"], 8],',
|
|
340
|
+
' ["main", [".Wrap_g7h8"], 1],',
|
|
341
|
+
' ["h1", [".Ttl_i9j0"], 1],',
|
|
342
|
+
' ["button", [".Cta_k1l2"], 6],',
|
|
343
|
+
' ["section", [".Blk_m3n4"], 8],',
|
|
344
|
+
' ["card", [".Itm_q7r8"], 6],',
|
|
345
|
+
' ["footer", [".Ftr_s9t0"], 1]',
|
|
346
|
+
' ]',
|
|
347
|
+
'',
|
|
348
|
+
` 3. Re-run with --roles ${rolesPath || 'roles.json'}`,
|
|
349
|
+
'',
|
|
350
|
+
'Selectors are tried in order and the first that matches anything wins, so',
|
|
351
|
+
'you can list the prototype selector and the build selector together in one',
|
|
352
|
+
'entry and the same role will resolve on both pages.'
|
|
353
|
+
];
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export { pxOf, norm, matches };
|
package/src/tt-map.mjs
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for reading TT layout IDs from captured section metadata.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export function formatTtTarget(section) {
|
|
6
|
+
if (!section) return null;
|
|
7
|
+
const row = section.ttRowId;
|
|
8
|
+
const blocks = section.ttBlockIds || [];
|
|
9
|
+
if (!row && !blocks.length) return null;
|
|
10
|
+
return {
|
|
11
|
+
row: row || null,
|
|
12
|
+
blocks,
|
|
13
|
+
hint: row
|
|
14
|
+
? `PUT /pages/{id}/layout — target ${row}${blocks.length ? ` / ${blocks.join(', ')}` : ''}`
|
|
15
|
+
: `blocks: ${blocks.join(', ')}`
|
|
16
|
+
};
|
|
17
|
+
}
|