@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/run.mjs
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { BANDS, GROUPS, ALL_PROPS } from './constants.mjs';
|
|
4
|
+
import { openPage, screenshotSection } from './capture.mjs';
|
|
5
|
+
import { inPageSections, alignSections } from './sections.mjs';
|
|
6
|
+
import { compareSectionPngs, sectionFailsGate } from './pixel.mjs';
|
|
7
|
+
import {
|
|
8
|
+
loadRoles,
|
|
9
|
+
filterRolesByScope,
|
|
10
|
+
inPageCapture,
|
|
11
|
+
diffStyles,
|
|
12
|
+
assessCoverage,
|
|
13
|
+
renderCoverageFailure
|
|
14
|
+
} from './styles.mjs';
|
|
15
|
+
import { resolveRenderedFonts, suppressFalseFontFamilyRows } from './fonts.mjs';
|
|
16
|
+
import { buildPlan, renderPixelReport, renderQueueMd, setRoleOrder, renderStyleEvidence } from './plan.mjs';
|
|
17
|
+
import { clusterSections } from './rank.mjs';
|
|
18
|
+
import { buildFidelityDocument } from './schema.mjs';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {object} FidelityOptions
|
|
22
|
+
* @property {string} [proto]
|
|
23
|
+
* @property {string} [draft]
|
|
24
|
+
* @property {Array<{id?: string, proto: string, draft: string}>} [pages]
|
|
25
|
+
* @property {string} [pagesFile] path to JSON/txt/sitemap list
|
|
26
|
+
* @property {number[]} [viewports]
|
|
27
|
+
* @property {number} [tolerance]
|
|
28
|
+
* @property {string[]} [only]
|
|
29
|
+
* @property {boolean} [menus]
|
|
30
|
+
* @property {string} [auth]
|
|
31
|
+
* @property {string} [draftAuth]
|
|
32
|
+
* @property {string} [cookie]
|
|
33
|
+
* @property {string} [roles]
|
|
34
|
+
* @property {string} [scope]
|
|
35
|
+
* @property {number} [round]
|
|
36
|
+
* @property {number} [maxRounds]
|
|
37
|
+
* @property {string} [json]
|
|
38
|
+
* @property {string} [outDir]
|
|
39
|
+
* @property {'full'|'pixel'|'styles'} [mode]
|
|
40
|
+
* @property {number} [threshold] fail when score >= this (default BANDS.clean)
|
|
41
|
+
* @property {boolean} [cacheBust]
|
|
42
|
+
* @property {number} [concurrency]
|
|
43
|
+
* @property {boolean} [planOnly]
|
|
44
|
+
* @property {boolean} [quiet]
|
|
45
|
+
* @property {boolean} [onlyFailing]
|
|
46
|
+
* @property {string} [priorJson]
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
function log(opts, msg) {
|
|
50
|
+
if (!opts.quiet) console.error(msg);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resolvePages(opts) {
|
|
54
|
+
if (Array.isArray(opts.pages) && opts.pages.length) {
|
|
55
|
+
return opts.pages.map((p, i) => ({
|
|
56
|
+
id: p.id || `page-${i + 1}`,
|
|
57
|
+
proto: p.proto,
|
|
58
|
+
draft: p.draft
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
if (opts.pagesFile) {
|
|
62
|
+
return loadPagesFile(opts.pagesFile, opts);
|
|
63
|
+
}
|
|
64
|
+
if (opts.proto && opts.draft) {
|
|
65
|
+
return [{ id: 'page-1', proto: opts.proto, draft: opts.draft }];
|
|
66
|
+
}
|
|
67
|
+
throw new Error('provide --proto and --draft, or --pages <file>, or pages[]');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function loadPagesFile(path, opts) {
|
|
71
|
+
const raw = readFileSync(path, 'utf8');
|
|
72
|
+
if (path.endsWith('.json')) {
|
|
73
|
+
const data = JSON.parse(raw);
|
|
74
|
+
if (Array.isArray(data)) {
|
|
75
|
+
return data.map((p, i) => ({
|
|
76
|
+
id: p.id || `page-${i + 1}`,
|
|
77
|
+
proto: p.proto || p.prototype,
|
|
78
|
+
draft: p.draft
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
throw new Error('--pages JSON must be an array of {id?, proto, draft}');
|
|
82
|
+
}
|
|
83
|
+
// txt: one draft path/url per line; pair with --proto as base or proto|draft per line
|
|
84
|
+
const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
|
|
85
|
+
return lines.map((line, i) => {
|
|
86
|
+
if (line.includes('|')) {
|
|
87
|
+
const [proto, draft] = line.split('|').map((s) => s.trim());
|
|
88
|
+
return { id: `page-${i + 1}`, proto, draft };
|
|
89
|
+
}
|
|
90
|
+
if (!opts.proto) throw new Error('txt --pages with draft-only lines requires --proto as template base');
|
|
91
|
+
return { id: `page-${i + 1}`, proto: opts.proto, draft: line };
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function measurePagePair(browser, pageSpec, opts, roles) {
|
|
96
|
+
const viewports = [];
|
|
97
|
+
const mode = opts.mode || 'full';
|
|
98
|
+
const doPixel = mode === 'full' || mode === 'pixel';
|
|
99
|
+
const doStyles = mode === 'full' || mode === 'styles';
|
|
100
|
+
const failThreshold = opts.threshold ?? BANDS.clean;
|
|
101
|
+
|
|
102
|
+
for (const width of opts.viewports) {
|
|
103
|
+
log(opts, ` ${pageSpec.id} @ ${width}px …`);
|
|
104
|
+
|
|
105
|
+
const protoOpen = await openPage(browser, {
|
|
106
|
+
url: pageSpec.proto,
|
|
107
|
+
width,
|
|
108
|
+
auth: opts.auth,
|
|
109
|
+
cacheBust: opts.cacheBust !== false,
|
|
110
|
+
menus: opts.menus
|
|
111
|
+
});
|
|
112
|
+
const draftOpen = await openPage(browser, {
|
|
113
|
+
url: pageSpec.draft,
|
|
114
|
+
width,
|
|
115
|
+
auth: opts.draftAuth || opts.auth,
|
|
116
|
+
cookie: opts.cookie,
|
|
117
|
+
cacheBust: opts.cacheBust !== false,
|
|
118
|
+
menus: opts.menus
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
let styleDiff = { elements: [], global: [], structural: [], total: 0 };
|
|
122
|
+
let coverage = { matched: [], onlyProto: [], onlyDraft: [], absentBoth: [], ratio: 1, unreliable: false };
|
|
123
|
+
let protoCapture = null;
|
|
124
|
+
let draftCapture = null;
|
|
125
|
+
|
|
126
|
+
if (doStyles) {
|
|
127
|
+
protoCapture = await protoOpen.page.evaluate(inPageCapture, { props: ALL_PROPS, roles });
|
|
128
|
+
draftCapture = await draftOpen.page.evaluate(inPageCapture, { props: ALL_PROPS, roles });
|
|
129
|
+
coverage = assessCoverage(protoCapture, draftCapture, roles);
|
|
130
|
+
styleDiff = diffStyles(protoCapture, draftCapture, opts.tolerance, opts.only);
|
|
131
|
+
try {
|
|
132
|
+
const sels = [
|
|
133
|
+
...new Set([
|
|
134
|
+
...styleDiff.elements.map((e) => e.protoSelector),
|
|
135
|
+
...styleDiff.elements.map((e) => e.draftSelector)
|
|
136
|
+
])
|
|
137
|
+
].slice(0, 20);
|
|
138
|
+
if (sels.length) {
|
|
139
|
+
const pf = await protoOpen.page.evaluate(resolveRenderedFonts, sels);
|
|
140
|
+
const df = await draftOpen.page.evaluate(resolveRenderedFonts, sels);
|
|
141
|
+
styleDiff.elements = suppressFalseFontFamilyRows(styleDiff.elements, pf, df);
|
|
142
|
+
styleDiff.total = styleDiff.global.length + styleDiff.elements.reduce((n, el) => n + el.rows.length, 0);
|
|
143
|
+
}
|
|
144
|
+
} catch (e) {
|
|
145
|
+
/* font probe optional */
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const sections = [];
|
|
150
|
+
if (doPixel) {
|
|
151
|
+
const protoSecs = await protoOpen.page.evaluate(inPageSections, { scope: opts.scope });
|
|
152
|
+
const draftSecs = await draftOpen.page.evaluate(inPageSections, { scope: opts.scope });
|
|
153
|
+
const { pairs, structural } = alignSections(protoSecs, draftSecs);
|
|
154
|
+
styleDiff.structural = [...(styleDiff.structural || []), ...structural];
|
|
155
|
+
|
|
156
|
+
const shotsDir = opts.outDir
|
|
157
|
+
? join(opts.outDir, 'shots', 'sections', pageSpec.id, String(width))
|
|
158
|
+
: null;
|
|
159
|
+
if (shotsDir) mkdirSync(join(shotsDir, 'prototype'), { recursive: true });
|
|
160
|
+
if (shotsDir) mkdirSync(join(shotsDir, 'draft'), { recursive: true });
|
|
161
|
+
if (shotsDir) mkdirSync(join(shotsDir, 'diff'), { recursive: true });
|
|
162
|
+
|
|
163
|
+
for (const pair of pairs) {
|
|
164
|
+
if (opts.onlyFailing && opts.priorClean?.has(`${pageSpec.id}:${width}:${pair.index}`)) {
|
|
165
|
+
sections.push({
|
|
166
|
+
index: pair.index,
|
|
167
|
+
label: pair.draft.label,
|
|
168
|
+
protoSelector: pair.proto.selector,
|
|
169
|
+
draftSelector: pair.draft.selector,
|
|
170
|
+
ttRowId: pair.draft.ttRowId,
|
|
171
|
+
ttBlockIds: pair.draft.ttBlockIds,
|
|
172
|
+
score: 0,
|
|
173
|
+
band: 'clean',
|
|
174
|
+
skipped: true
|
|
175
|
+
});
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const protoPng = await screenshotSection(protoOpen.page, pair.proto);
|
|
180
|
+
const draftPng = await screenshotSection(draftOpen.page, pair.draft);
|
|
181
|
+
const cmp = compareSectionPngs(protoPng, draftPng, {
|
|
182
|
+
includeDiff: Boolean(shotsDir),
|
|
183
|
+
metric: opts.metric || 'hybrid',
|
|
184
|
+
threshold: Number.isFinite(opts.aaThreshold) ? opts.aaThreshold : undefined
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const shotPaths = {};
|
|
188
|
+
if (shotsDir) {
|
|
189
|
+
const base = String(pair.index).padStart(2, '0');
|
|
190
|
+
const pp = join(shotsDir, 'prototype', `${base}.png`);
|
|
191
|
+
const dp = join(shotsDir, 'draft', `${base}.png`);
|
|
192
|
+
const dif = join(shotsDir, 'diff', `${base}.png`);
|
|
193
|
+
writeFileSync(pp, protoPng);
|
|
194
|
+
writeFileSync(dp, draftPng);
|
|
195
|
+
if (cmp.diffPng) writeFileSync(dif, cmp.diffPng);
|
|
196
|
+
shotPaths.proto = pp;
|
|
197
|
+
shotPaths.draft = dp;
|
|
198
|
+
shotPaths.diff = dif;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
sections.push({
|
|
202
|
+
index: pair.index,
|
|
203
|
+
label: pair.draft.label || pair.proto.label,
|
|
204
|
+
protoSelector: pair.proto.selector,
|
|
205
|
+
draftSelector: pair.draft.selector,
|
|
206
|
+
ttRowId: pair.draft.ttRowId,
|
|
207
|
+
ttBlockIds: pair.draft.ttBlockIds,
|
|
208
|
+
score: cmp.score,
|
|
209
|
+
band: cmp.band,
|
|
210
|
+
metric: cmp.metric,
|
|
211
|
+
pixelRatio: cmp.pixelRatio,
|
|
212
|
+
ssim: cmp.ssim,
|
|
213
|
+
diffPixels: cmp.diffPixels,
|
|
214
|
+
shotPaths
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
await protoOpen.context.close();
|
|
220
|
+
await draftOpen.context.close();
|
|
221
|
+
|
|
222
|
+
const plan = buildPlan(styleDiff, { pixelSections: sections });
|
|
223
|
+
viewports.push({
|
|
224
|
+
width,
|
|
225
|
+
coverage,
|
|
226
|
+
styleDiff,
|
|
227
|
+
sections,
|
|
228
|
+
cacheHeaders: draftOpen.cacheHeaders,
|
|
229
|
+
plan
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
id: pageSpec.id,
|
|
235
|
+
proto: pageSpec.proto,
|
|
236
|
+
draft: pageSpec.draft,
|
|
237
|
+
viewports
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function loadPriorClean(priorJson) {
|
|
242
|
+
const set = new Set();
|
|
243
|
+
if (!priorJson || !existsSync(priorJson)) return set;
|
|
244
|
+
try {
|
|
245
|
+
const doc = JSON.parse(readFileSync(priorJson, 'utf8'));
|
|
246
|
+
for (const page of doc.pages || []) {
|
|
247
|
+
for (const vp of page.viewports || []) {
|
|
248
|
+
for (const s of vp.sections || []) {
|
|
249
|
+
if (s.band === 'clean') set.add(`${page.id}:${vp.width}:${s.index}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
} catch (e) { /* ignore */ }
|
|
254
|
+
return set;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Programmatic entry: run a fidelity comparison.
|
|
259
|
+
* @param {FidelityOptions} opts
|
|
260
|
+
* @returns {Promise<{ exitCode: number, summary: object, pages: object[], plan: object[], document: object, text: string }>}
|
|
261
|
+
*/
|
|
262
|
+
export async function runFidelity(opts = {}) {
|
|
263
|
+
const options = {
|
|
264
|
+
viewports: [1280, 768, 390],
|
|
265
|
+
tolerance: 1,
|
|
266
|
+
mode: 'full',
|
|
267
|
+
scope: 'full',
|
|
268
|
+
round: 1,
|
|
269
|
+
maxRounds: 6,
|
|
270
|
+
cacheBust: true,
|
|
271
|
+
concurrency: 1,
|
|
272
|
+
threshold: BANDS.clean,
|
|
273
|
+
metric: 'hybrid',
|
|
274
|
+
...opts
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
let roles;
|
|
278
|
+
try {
|
|
279
|
+
roles = filterRolesByScope(loadRoles(options.roles), options.scope);
|
|
280
|
+
} catch (err) {
|
|
281
|
+
throw Object.assign(new Error(err.message), { exitCode: 2 });
|
|
282
|
+
}
|
|
283
|
+
if (!roles.length) {
|
|
284
|
+
throw Object.assign(new Error('--scope left no roles to measure.'), { exitCode: 2 });
|
|
285
|
+
}
|
|
286
|
+
setRoleOrder(roles);
|
|
287
|
+
|
|
288
|
+
const pagesSpec = resolvePages(options);
|
|
289
|
+
if (options.outDir) mkdirSync(options.outDir, { recursive: true });
|
|
290
|
+
|
|
291
|
+
let chromium;
|
|
292
|
+
try {
|
|
293
|
+
({ chromium } = await import('playwright'));
|
|
294
|
+
} catch (err) {
|
|
295
|
+
throw Object.assign(
|
|
296
|
+
new Error('Playwright required. Run: npm i -D playwright && npx playwright install chromium'),
|
|
297
|
+
{ exitCode: 2 }
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
options.priorClean = options.onlyFailing
|
|
302
|
+
? loadPriorClean(options.priorJson || (options.outDir && join(options.outDir, 'fidelity.json')))
|
|
303
|
+
: new Set();
|
|
304
|
+
|
|
305
|
+
const browser = await chromium.launch();
|
|
306
|
+
const pages = [];
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
const conc = Math.max(1, options.concurrency | 0);
|
|
310
|
+
for (let i = 0; i < pagesSpec.length; i += conc) {
|
|
311
|
+
const batch = pagesSpec.slice(i, i + conc);
|
|
312
|
+
const measured = await Promise.all(
|
|
313
|
+
batch.map((p) => measurePagePair(browser, p, options, roles))
|
|
314
|
+
);
|
|
315
|
+
pages.push(...measured);
|
|
316
|
+
}
|
|
317
|
+
} finally {
|
|
318
|
+
await browser.close();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Coverage guard (styles path)
|
|
322
|
+
if (options.mode === 'full' || options.mode === 'styles') {
|
|
323
|
+
for (const page of pages) {
|
|
324
|
+
for (const vp of page.viewports) {
|
|
325
|
+
if (vp.coverage?.unreliable) {
|
|
326
|
+
const text = renderCoverageFailure(vp.coverage, options.roles).join('\n');
|
|
327
|
+
return {
|
|
328
|
+
exitCode: 2,
|
|
329
|
+
summary: { error: 'low_coverage' },
|
|
330
|
+
pages,
|
|
331
|
+
plan: [],
|
|
332
|
+
document: { error: 'low_coverage', pages },
|
|
333
|
+
text
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const clusters = clusterSections(pages);
|
|
341
|
+
const flatPlan = [];
|
|
342
|
+
for (const page of pages) {
|
|
343
|
+
for (const vp of page.viewports) {
|
|
344
|
+
for (const t of vp.plan?.tasks || []) {
|
|
345
|
+
flatPlan.push({ ...t, pageId: page.id, width: vp.width });
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const mode = options.mode;
|
|
351
|
+
let dirty = false;
|
|
352
|
+
if (mode === 'styles') {
|
|
353
|
+
dirty = pages.some((p) =>
|
|
354
|
+
p.viewports.some((vp) => vp.styleDiff?.total || vp.styleDiff?.structural?.length)
|
|
355
|
+
);
|
|
356
|
+
} else {
|
|
357
|
+
dirty = pages.some((p) =>
|
|
358
|
+
p.viewports.some((vp) =>
|
|
359
|
+
(vp.sections || []).some((s) => sectionFailsGate(s.score, options.threshold))
|
|
360
|
+
|| (vp.styleDiff?.structural || []).some((s) => s.kind === 'count' && s.role === 'section')
|
|
361
|
+
)
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
let exitCode = dirty ? 1 : 0;
|
|
366
|
+
if (dirty && options.round >= options.maxRounds) exitCode = 3;
|
|
367
|
+
|
|
368
|
+
const document = buildFidelityDocument({
|
|
369
|
+
mode,
|
|
370
|
+
threshold: options.threshold,
|
|
371
|
+
round: options.round,
|
|
372
|
+
maxRounds: options.maxRounds,
|
|
373
|
+
pages,
|
|
374
|
+
clusters,
|
|
375
|
+
workPlan: flatPlan,
|
|
376
|
+
exitCode
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
if (options.outDir) {
|
|
380
|
+
writeFileSync(join(options.outDir, 'fidelity.json'), JSON.stringify(document, null, 2));
|
|
381
|
+
writeFileSync(join(options.outDir, 'QUEUE.md'), renderQueueMd(clusters, pages));
|
|
382
|
+
log(options, `wrote ${join(options.outDir, 'fidelity.json')}`);
|
|
383
|
+
}
|
|
384
|
+
if (options.json) {
|
|
385
|
+
writeFileSync(options.json, JSON.stringify(document, null, 2));
|
|
386
|
+
log(options, `wrote ${options.json}`);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
let text = '';
|
|
390
|
+
if (mode === 'styles') {
|
|
391
|
+
const styleResults = pages.flatMap((p) =>
|
|
392
|
+
p.viewports.map((vp) => ({
|
|
393
|
+
width: vp.width,
|
|
394
|
+
coverage: vp.coverage,
|
|
395
|
+
diff: vp.styleDiff,
|
|
396
|
+
pixelSections: []
|
|
397
|
+
}))
|
|
398
|
+
);
|
|
399
|
+
text = renderStyleEvidence(styleResults, options.tolerance, options.planOnly);
|
|
400
|
+
} else {
|
|
401
|
+
text = renderPixelReport(pages, { mode });
|
|
402
|
+
if (mode === 'full' && !options.planOnly) {
|
|
403
|
+
const styleResults = pages.flatMap((p) =>
|
|
404
|
+
p.viewports.map((vp) => ({
|
|
405
|
+
width: vp.width,
|
|
406
|
+
coverage: vp.coverage,
|
|
407
|
+
diff: vp.styleDiff,
|
|
408
|
+
pixelSections: vp.sections
|
|
409
|
+
}))
|
|
410
|
+
);
|
|
411
|
+
text += '\n\n' + renderStyleEvidence(styleResults, options.tolerance, true);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (options.round > 1 || options.maxRounds !== 6) {
|
|
416
|
+
text += `\n\nFIDELITY LOOP: round ${options.round} of ${options.maxRounds}.`;
|
|
417
|
+
}
|
|
418
|
+
if (exitCode === 3) {
|
|
419
|
+
text += `\n\nMAX ROUNDS REACHED (${options.round}/${options.maxRounds}) — STOP LOOPING`;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return {
|
|
423
|
+
exitCode,
|
|
424
|
+
summary: document.summary,
|
|
425
|
+
pages,
|
|
426
|
+
plan: flatPlan,
|
|
427
|
+
clusters,
|
|
428
|
+
document,
|
|
429
|
+
text
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export { GROUPS, BANDS, ALL_PROPS };
|
package/src/schema.mjs
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { SCHEMA_VERSION, PACKAGE_VERSION, BANDS } from './constants.mjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build fidelity.json v2 document.
|
|
5
|
+
*/
|
|
6
|
+
export function buildFidelityDocument({
|
|
7
|
+
mode,
|
|
8
|
+
threshold,
|
|
9
|
+
round,
|
|
10
|
+
maxRounds,
|
|
11
|
+
pages,
|
|
12
|
+
clusters,
|
|
13
|
+
workPlan,
|
|
14
|
+
exitCode
|
|
15
|
+
}) {
|
|
16
|
+
return {
|
|
17
|
+
schema_version: SCHEMA_VERSION,
|
|
18
|
+
package_version: PACKAGE_VERSION,
|
|
19
|
+
mode,
|
|
20
|
+
threshold: {
|
|
21
|
+
clean: threshold ?? BANDS.clean,
|
|
22
|
+
fix: BANDS.fix,
|
|
23
|
+
rebuild: BANDS.fix
|
|
24
|
+
},
|
|
25
|
+
round,
|
|
26
|
+
max_rounds: maxRounds,
|
|
27
|
+
exit_code: exitCode,
|
|
28
|
+
summary: summarize(pages),
|
|
29
|
+
clusters: clusters || [],
|
|
30
|
+
work_plan: workPlan || [],
|
|
31
|
+
pages
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function summarize(pages) {
|
|
36
|
+
let sections = 0;
|
|
37
|
+
let failing = 0;
|
|
38
|
+
let rebuild = 0;
|
|
39
|
+
for (const page of pages) {
|
|
40
|
+
for (const vp of page.viewports || []) {
|
|
41
|
+
for (const s of vp.sections || []) {
|
|
42
|
+
sections += 1;
|
|
43
|
+
if (s.band !== 'clean') failing += 1;
|
|
44
|
+
if (s.band === 'rebuild') rebuild += 1;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
pages: pages.length,
|
|
50
|
+
sections,
|
|
51
|
+
failing,
|
|
52
|
+
rebuild,
|
|
53
|
+
clean: sections - failing
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function mapTtFromSection(section) {
|
|
58
|
+
return {
|
|
59
|
+
ttRowId: section.ttRowId || null,
|
|
60
|
+
ttBlockIds: Array.isArray(section.ttBlockIds) ? section.ttBlockIds : []
|
|
61
|
+
};
|
|
62
|
+
}
|
package/src/sections.mjs
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section discovery for pixel grading.
|
|
3
|
+
* Draft (TT): prefer .tt-pb-row / data-tt-row.
|
|
4
|
+
* Prototype: prefer main > section, then body sections.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/* eslint-disable no-undef */
|
|
8
|
+
export function inPageSections({ scope }) {
|
|
9
|
+
const visible = (el) => {
|
|
10
|
+
if (!el) return false;
|
|
11
|
+
const cs = getComputedStyle(el);
|
|
12
|
+
if (cs.visibility === 'hidden' || cs.display === 'none') return false;
|
|
13
|
+
const r = el.getBoundingClientRect();
|
|
14
|
+
return r.width > 2 && r.height > 2;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const selOf = (el) => {
|
|
18
|
+
if (!el) return '';
|
|
19
|
+
if (el.getAttribute('data-tt-row')) return `[data-tt-row="${el.getAttribute('data-tt-row')}"]`;
|
|
20
|
+
if (el.id) return `#${CSS.escape(el.id)}`;
|
|
21
|
+
const tag = el.tagName.toLowerCase();
|
|
22
|
+
const parent = el.parentElement;
|
|
23
|
+
if (!parent) return tag;
|
|
24
|
+
const sibs = [...parent.children].filter((c) => c.tagName === el.tagName);
|
|
25
|
+
const idx = sibs.indexOf(el) + 1;
|
|
26
|
+
return `${tag}:nth-of-type(${idx})`;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const heading = (el) => {
|
|
30
|
+
const h = el.querySelector('h1,h2,h3,h4,[class*=headline i],[class*=heading i]');
|
|
31
|
+
return h ? (h.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 80) : '';
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const box = (el) => {
|
|
35
|
+
const r = el.getBoundingClientRect();
|
|
36
|
+
const top = Math.round(r.top + window.scrollY);
|
|
37
|
+
return {
|
|
38
|
+
x: Math.round(r.left),
|
|
39
|
+
y: top,
|
|
40
|
+
width: Math.round(r.width),
|
|
41
|
+
height: Math.round(r.height),
|
|
42
|
+
viewportTop: Math.round(r.top),
|
|
43
|
+
viewportLeft: Math.round(r.left)
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const ttMeta = (el) => {
|
|
48
|
+
const rowId = el.getAttribute('data-tt-row') || (el.id && /^row_/.test(el.id) ? el.id : '');
|
|
49
|
+
const blocks = [...el.querySelectorAll('[data-tt-block], .tt-pb-block[id^="blk_"]')]
|
|
50
|
+
.map((b) => b.getAttribute('data-tt-block') || b.id)
|
|
51
|
+
.filter(Boolean);
|
|
52
|
+
return { ttRowId: rowId || null, ttBlockIds: blocks };
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
let nodes = [];
|
|
56
|
+
const s = String(scope || 'full').toLowerCase();
|
|
57
|
+
|
|
58
|
+
if (s === 'chrome') {
|
|
59
|
+
const chrome = [
|
|
60
|
+
...document.querySelectorAll('header, [role=banner], .site-header, footer, [role=contentinfo], .site-footer')
|
|
61
|
+
].filter(visible);
|
|
62
|
+
nodes = chrome;
|
|
63
|
+
} else {
|
|
64
|
+
const ttRows = [...document.querySelectorAll('.tt-pb-row, [data-tt-row]')].filter(visible);
|
|
65
|
+
if (ttRows.length) {
|
|
66
|
+
nodes = ttRows;
|
|
67
|
+
} else {
|
|
68
|
+
const main = document.querySelector('main, [role=main], .site-main') || document.body;
|
|
69
|
+
const sections = [...main.querySelectorAll(':scope > section')].filter(visible);
|
|
70
|
+
if (sections.length) {
|
|
71
|
+
nodes = sections;
|
|
72
|
+
} else {
|
|
73
|
+
nodes = [...main.children].filter(
|
|
74
|
+
(el) => visible(el) && !['SCRIPT', 'STYLE', 'LINK', 'NOSCRIPT'].includes(el.tagName)
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (s === 'content') {
|
|
79
|
+
nodes = nodes.filter((el) => !el.closest('header, footer, [role=banner], [role=contentinfo]'));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return nodes.map((el, i) => {
|
|
84
|
+
const meta = ttMeta(el);
|
|
85
|
+
return {
|
|
86
|
+
index: i,
|
|
87
|
+
selector: selOf(el),
|
|
88
|
+
label: heading(el) || (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 48),
|
|
89
|
+
box: box(el),
|
|
90
|
+
ttRowId: meta.ttRowId,
|
|
91
|
+
ttBlockIds: meta.ttBlockIds
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
/* eslint-enable no-undef */
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Align sections by document order. Length mismatch → structural findings.
|
|
99
|
+
*/
|
|
100
|
+
export function alignSections(protoSections, draftSections) {
|
|
101
|
+
const n = Math.min(protoSections.length, draftSections.length);
|
|
102
|
+
const pairs = [];
|
|
103
|
+
for (let i = 0; i < n; i += 1) {
|
|
104
|
+
pairs.push({ index: i, proto: protoSections[i], draft: draftSections[i] });
|
|
105
|
+
}
|
|
106
|
+
const structural = [];
|
|
107
|
+
if (protoSections.length !== draftSections.length) {
|
|
108
|
+
structural.push({
|
|
109
|
+
kind: 'count',
|
|
110
|
+
role: 'section',
|
|
111
|
+
text: `section: prototype has ${protoSections.length}, draft has ${draftSections.length}${
|
|
112
|
+
draftSections.length < protoSections.length
|
|
113
|
+
? ` (draft is missing ${protoSections.length - draftSections.length})`
|
|
114
|
+
: ` (draft has ${draftSections.length - protoSections.length} extra)`
|
|
115
|
+
}`
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return { pairs, structural };
|
|
119
|
+
}
|