@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/src/pixel.mjs ADDED
@@ -0,0 +1,197 @@
1
+ import { createRequire } from 'node:module';
2
+ import { BANDS } from './constants.mjs';
3
+
4
+ const require = createRequire(import.meta.url);
5
+ const { PNG } = require('pngjs');
6
+ const pixelmatchMod = require('pixelmatch');
7
+ const pixelmatch = typeof pixelmatchMod === 'function' ? pixelmatchMod : pixelmatchMod.default;
8
+
9
+ /** Default pixelmatch colour threshold (0–1). Slightly tolerant of AA. */
10
+ export const DEFAULT_AA_THRESHOLD = 0.14;
11
+
12
+ /**
13
+ * Build luminance plane (0–255) from RGBA buffer.
14
+ * @param {Uint8Array|Buffer} data
15
+ * @param {number} width
16
+ * @param {number} height
17
+ * @returns {Float64Array}
18
+ */
19
+ function luminancePlane(data, width, height) {
20
+ const out = new Float64Array(width * height);
21
+ for (let i = 0, p = 0; i < out.length; i += 1, p += 4) {
22
+ const r = data[p];
23
+ const g = data[p + 1];
24
+ const b = data[p + 2];
25
+ const a = data[p + 3] / 255;
26
+ // Premultiply-ish against white for transparent pads
27
+ const rr = r * a + 255 * (1 - a);
28
+ const gg = g * a + 255 * (1 - a);
29
+ const bb = b * a + 255 * (1 - a);
30
+ out[i] = 0.2126 * rr + 0.7152 * gg + 0.0722 * bb;
31
+ }
32
+ return out;
33
+ }
34
+
35
+ /**
36
+ * Mean SSIM over 8×8 windows (simplified but stable for section screenshots).
37
+ * Returns 1 = identical, 0 = unrelated.
38
+ */
39
+ export function computeSsim(dataA, dataB, width, height) {
40
+ const L = 255;
41
+ const c1 = (0.01 * L) ** 2;
42
+ const c2 = (0.03 * L) ** 2;
43
+ const a = luminancePlane(dataA, width, height);
44
+ const b = luminancePlane(dataB, width, height);
45
+ const win = 8;
46
+ let sum = 0;
47
+ let count = 0;
48
+
49
+ for (let y = 0; y + win <= height; y += win) {
50
+ for (let x = 0; x + win <= width; x += win) {
51
+ let n = 0;
52
+ let meanA = 0;
53
+ let meanB = 0;
54
+ for (let j = 0; j < win; j += 1) {
55
+ for (let i = 0; i < win; i += 1) {
56
+ const idx = (y + j) * width + (x + i);
57
+ meanA += a[idx];
58
+ meanB += b[idx];
59
+ n += 1;
60
+ }
61
+ }
62
+ meanA /= n;
63
+ meanB /= n;
64
+ let varA = 0;
65
+ let varB = 0;
66
+ let cov = 0;
67
+ for (let j = 0; j < win; j += 1) {
68
+ for (let i = 0; i < win; i += 1) {
69
+ const idx = (y + j) * width + (x + i);
70
+ const da = a[idx] - meanA;
71
+ const db = b[idx] - meanB;
72
+ varA += da * da;
73
+ varB += db * db;
74
+ cov += da * db;
75
+ }
76
+ }
77
+ varA /= n;
78
+ varB /= n;
79
+ cov /= n;
80
+ const num = (2 * meanA * meanB + c1) * (2 * cov + c2);
81
+ const den = (meanA * meanA + meanB * meanB + c1) * (varA + varB + c2);
82
+ sum += den > 0 ? num / den : 1;
83
+ count += 1;
84
+ }
85
+ }
86
+
87
+ if (!count) {
88
+ // Tiny images: fall back to global means
89
+ let meanA = 0;
90
+ let meanB = 0;
91
+ const n = a.length || 1;
92
+ for (let i = 0; i < a.length; i += 1) {
93
+ meanA += a[i];
94
+ meanB += b[i];
95
+ }
96
+ meanA /= n;
97
+ meanB /= n;
98
+ let varA = 0;
99
+ let varB = 0;
100
+ let cov = 0;
101
+ for (let i = 0; i < a.length; i += 1) {
102
+ const da = a[i] - meanA;
103
+ const db = b[i] - meanB;
104
+ varA += da * da;
105
+ varB += db * db;
106
+ cov += da * db;
107
+ }
108
+ varA /= n;
109
+ varB /= n;
110
+ cov /= n;
111
+ const num = (2 * meanA * meanB + c1) * (2 * cov + c2);
112
+ const den = (meanA * meanA + meanB * meanB + c1) * (varA + varB + c2);
113
+ return den > 0 ? num / den : 1;
114
+ }
115
+ return sum / count;
116
+ }
117
+
118
+ /**
119
+ * Normalize two PNG buffers and compare.
120
+ * @param {Buffer} protoBuf
121
+ * @param {Buffer} draftBuf
122
+ * @param {{ threshold?: number, includeDiff?: boolean, metric?: 'pixel'|'ssim'|'hybrid' }} [opts]
123
+ * @returns {{ score: number, pixelRatio: number, ssim: number, ssimDivergence: number, diffPixels: number, area: number, width: number, height: number, metric: string, band: string, diffPng?: Buffer }}
124
+ */
125
+ export function compareSectionPngs(protoBuf, draftBuf, {
126
+ threshold = DEFAULT_AA_THRESHOLD,
127
+ includeDiff = false,
128
+ metric = 'hybrid'
129
+ } = {}) {
130
+ const a = PNG.sync.read(protoBuf);
131
+ const b = PNG.sync.read(draftBuf);
132
+ const width = Math.max(a.width, b.width);
133
+ const height = Math.max(a.height, b.height);
134
+
135
+ const canvasA = new PNG({ width, height, fill: true });
136
+ const canvasB = new PNG({ width, height, fill: true });
137
+ for (let i = 0; i < canvasA.data.length; i += 4) {
138
+ canvasA.data[i] = 255;
139
+ canvasA.data[i + 1] = 255;
140
+ canvasA.data[i + 2] = 255;
141
+ canvasA.data[i + 3] = 255;
142
+ canvasB.data[i] = 255;
143
+ canvasB.data[i + 1] = 255;
144
+ canvasB.data[i + 2] = 255;
145
+ canvasB.data[i + 3] = 255;
146
+ }
147
+ PNG.bitblt(a, canvasA, 0, 0, a.width, a.height, 0, 0);
148
+ PNG.bitblt(b, canvasB, 0, 0, b.width, b.height, 0, 0);
149
+
150
+ const diff = new PNG({ width, height });
151
+ const diffPixels = pixelmatch(canvasA.data, canvasB.data, diff.data, width, height, {
152
+ threshold,
153
+ includeAA: true
154
+ });
155
+ const area = width * height;
156
+ const pixelRatio = area > 0 ? diffPixels / area : 0;
157
+ const ssim = computeSsim(canvasA.data, canvasB.data, width, height);
158
+ const ssimDivergence = Math.max(0, Math.min(1, 1 - ssim));
159
+
160
+ const m = String(metric || 'hybrid').toLowerCase();
161
+ let score;
162
+ if (m === 'ssim') {
163
+ score = ssimDivergence;
164
+ } else if (m === 'pixel') {
165
+ score = pixelRatio;
166
+ } else {
167
+ // hybrid: perceptual SSIM primary; pixel ratio floors obvious paint misses
168
+ score = Math.max(ssimDivergence, pixelRatio * 0.85);
169
+ }
170
+
171
+ const out = {
172
+ score,
173
+ pixelRatio,
174
+ ssim,
175
+ ssimDivergence,
176
+ diffPixels,
177
+ area,
178
+ width,
179
+ height,
180
+ metric: m === 'ssim' || m === 'pixel' ? m : 'hybrid',
181
+ band: scoreBand(score)
182
+ };
183
+ if (includeDiff) {
184
+ out.diffPng = PNG.sync.write(diff);
185
+ }
186
+ return out;
187
+ }
188
+
189
+ export function scoreBand(score) {
190
+ if (score < BANDS.clean) return 'clean';
191
+ if (score < BANDS.fix) return 'fix';
192
+ return 'rebuild';
193
+ }
194
+
195
+ export function sectionFailsGate(score, failThreshold = BANDS.clean) {
196
+ return score >= failThreshold;
197
+ }
package/src/plan.mjs ADDED
@@ -0,0 +1,410 @@
1
+ import { CHROME_ROLES } from './constants.mjs';
2
+
3
+ const PROP_FAMILY = {
4
+ 'padding-top': 'padding', 'padding-right': 'padding',
5
+ 'padding-bottom': 'padding', 'padding-left': 'padding',
6
+ 'margin-top': 'margin', 'margin-right': 'margin',
7
+ 'margin-bottom': 'margin', 'margin-left': 'margin',
8
+ 'font-size': 'type', 'line-height': 'type', 'letter-spacing': 'type',
9
+ 'text-transform': 'type', 'font-style': 'type', 'font-weight': 'type',
10
+ 'font-family': 'family',
11
+ color: 'colour', 'background-color': 'colour',
12
+ 'border-top-color': 'border', 'border-bottom-color': 'border',
13
+ 'border-left-color': 'border', 'border-right-color': 'border',
14
+ 'border-top-width': 'border', 'border-bottom-width': 'border',
15
+ 'border-left-width': 'border', 'border-right-width': 'border',
16
+ 'border-radius': 'radius',
17
+ 'box-shadow': 'shadow',
18
+ opacity: 'opacity',
19
+ gap: 'gap', 'row-gap': 'gap', 'column-gap': 'gap',
20
+ 'grid-template-columns': 'columns',
21
+ 'max-width': 'size', 'min-height': 'size',
22
+ display: 'layout', position: 'layout', 'flex-direction': 'layout', 'flex-wrap': 'layout',
23
+ 'justify-content': 'align', 'align-items': 'align', 'text-align': 'align', 'vertical-align': 'align',
24
+ 'transition-duration': 'motion', 'transition-property': 'motion', 'animation-name': 'motion',
25
+ 'object-fit': 'media', 'object-position': 'media'
26
+ };
27
+
28
+ const FAMILY_LABEL = {
29
+ padding: 'padding', margin: 'margin', type: 'type scale', family: 'font family',
30
+ colour: 'colour', border: 'borders', radius: 'corner radius', shadow: 'shadow',
31
+ opacity: 'opacity', gap: 'gap', columns: 'column count', size: 'size limits',
32
+ layout: 'layout mode', align: 'alignment', motion: 'motion', media: 'image fit',
33
+ pixel: 'visual / pixel'
34
+ };
35
+
36
+ const FIX_HINT = {
37
+ family: 'Design preset → typography (base font). Set ONCE globally, never per element.',
38
+ type: 'If it affects headings site-wide, the design preset type scale. Otherwise the block’s Style → Typography.',
39
+ colour: 'If the pair recurs, map a design preset palette role. Otherwise block Style → Colour.',
40
+ border: 'Design preset border token if it recurs; otherwise block Style → Border.',
41
+ radius: 'Design preset radius token if global; otherwise block Style → Border radius.',
42
+ shadow: 'Design preset shadow token if global; otherwise block Style → Shadow.',
43
+ padding: 'Block Style → Spacing on the row / column / block. Not Custom CSS.',
44
+ margin: 'Block Style → Spacing (outer). Check for a doubled gap before adding margin.',
45
+ gap: 'The containing row’s gap setting — not a margin on each child.',
46
+ columns: 'The row’s column count / grid setting. Never emulate with widths.',
47
+ size: 'Row content width (full-bleed vs inset) or block Style → Dimensions.',
48
+ layout: 'The row/column layout setting (stack vs row, wrap). Not Custom CSS.',
49
+ align: 'Block Style → Alignment on the block or its column.',
50
+ motion: 'Block motion setting; minimal Custom CSS only if the platform cannot express it.',
51
+ opacity: 'Block Style → Colour / opacity.',
52
+ media: 'Image block fit / crop setting, or re-sideload a correctly cropped asset.',
53
+ pixel: 'Inspect section screenshots; fix via tokens → block settings → minimal Custom CSS. Re-measure with tt-fidelity.'
54
+ };
55
+
56
+ const FAMILY_RANK = [
57
+ 'layout', 'columns', 'size', 'gap', 'padding', 'margin', 'align',
58
+ 'family', 'type', 'border', 'radius', 'shadow', 'colour', 'opacity',
59
+ 'motion', 'media', 'pixel'
60
+ ];
61
+
62
+ const CHROME_FIX = 'Header/footer builder (GET/PUT /header, /footer) — not page content.';
63
+ const MENU_FIX = 'Menu structure via POST/PUT /menus with real page IDs — add the item, do not style around it.';
64
+
65
+ let roleOrder = [];
66
+
67
+ export function setRoleOrder(roles) {
68
+ roleOrder = roles.map(([r]) => r);
69
+ }
70
+
71
+ const roleRank = (role) => {
72
+ const i = roleOrder.indexOf(role);
73
+ return i === -1 ? roleOrder.length : i;
74
+ };
75
+
76
+ function familyOf(prop) {
77
+ return PROP_FAMILY[prop] || 'layout';
78
+ }
79
+
80
+ function pxOf(v) {
81
+ const m = /^(-?[\d.]+)px$/.exec(String(v ?? '').trim());
82
+ return m ? parseFloat(m[1]) : null;
83
+ }
84
+
85
+ function describeTracks(value) {
86
+ const tracks = String(value || '').trim().split(/\s+/).filter(Boolean);
87
+ if (!tracks.length) return String(value || '');
88
+ const px = tracks.map((t) => pxOf(t)).filter((n) => n !== null);
89
+ const uniform = px.length === tracks.length && px.every((n) => Math.abs(n - px[0]) < 1);
90
+ const detail = uniform ? `${Math.round(px[0])}px each` : tracks.join(' ');
91
+ return `${tracks.length} column${tracks.length === 1 ? '' : 's'} (${detail})`;
92
+ }
93
+
94
+ function describeRow(row) {
95
+ if (row.prop === 'grid-template-columns') {
96
+ return `column count: ${describeTracks(row.proto)} -> ${describeTracks(row.draft)}`;
97
+ }
98
+ return `${row.prop}: ${row.proto} -> ${row.draft}`;
99
+ }
100
+
101
+ function fixHintFor(family, role, kind) {
102
+ if (kind === 'structural') return role && role.startsWith('nav') ? MENU_FIX : 'Page layout — add the missing section via PUT /pages/{id}/layout.';
103
+ if (kind === 'pixel') {
104
+ return 'Visual divergence — inspect shots; tokens → block Style → minimal Custom CSS. Cite section score in the fidelity report.';
105
+ }
106
+ if (CHROME_ROLES.has(role) && family !== 'family') return CHROME_FIX;
107
+ return FIX_HINT[family] || 'Block Style first; minimal Custom CSS only as a last resort.';
108
+ }
109
+
110
+ /**
111
+ * Build ordered work plan from style diff + optional pixel section findings.
112
+ */
113
+ export function buildPlan(d, { pixelSections = [] } = {}) {
114
+ const tasks = [];
115
+
116
+ const structuralByRole = new Map();
117
+ for (const s of d.structural || []) {
118
+ const role = s.role || 'structure';
119
+ if (!structuralByRole.has(role)) structuralByRole.set(role, []);
120
+ structuralByRole.get(role).push(s);
121
+ }
122
+ for (const [role, items] of structuralByRole) {
123
+ const unmatched = items.some((i) => i.kind === 'unmatched' || i.kind === 'extra');
124
+ tasks.push({
125
+ kind: 'structural',
126
+ title: unmatched
127
+ ? `Confirm ${role} — matched on one page only`
128
+ : `Reconcile ${role} — count differs from the prototype`,
129
+ why: items.map((i) => i.text),
130
+ fixAt: unmatched
131
+ ? `FIRST verify it is really absent (inspect the page). If it exists under different markup, add its selector via --roles and re-run — do not "fix" it. If genuinely absent: ${fixHintFor(null, role, 'structural')}`
132
+ : fixHintFor(null, role, 'structural'),
133
+ rows: [],
134
+ affects: items.length,
135
+ mayResolve: 0
136
+ });
137
+ }
138
+
139
+ // Pixel sections — severity-ranked, before global style (rebuild first).
140
+ const failing = (pixelSections || [])
141
+ .filter((s) => s.band && s.band !== 'clean')
142
+ .sort((a, b) => b.score - a.score);
143
+ for (const sec of failing) {
144
+ const idHint = sec.ttRowId
145
+ ? `row ${sec.ttRowId}${sec.ttBlockIds?.length ? ` → blocks ${sec.ttBlockIds.join(', ')}` : ''}`
146
+ : `section ${sec.index}`;
147
+ tasks.push({
148
+ kind: 'pixel',
149
+ severity: sec.band === 'rebuild' ? 'P0' : 'P1',
150
+ title: `[${sec.band.toUpperCase()}] ${idHint}: ${(sec.score * 100).toFixed(1)}% pixels differ`,
151
+ why: [
152
+ `label: "${sec.label || ''}"`,
153
+ `proto: ${sec.protoSelector}`,
154
+ `draft: ${sec.draftSelector}`,
155
+ sec.shotPaths ? `shots: ${sec.shotPaths.proto} | ${sec.shotPaths.draft}` : 'shots: see --out shots/'
156
+ ],
157
+ fixAt: fixHintFor('pixel', null, 'pixel'),
158
+ rows: [],
159
+ affects: 1,
160
+ mayResolve: 0,
161
+ ttRowId: sec.ttRowId || null,
162
+ ttBlockIds: sec.ttBlockIds || [],
163
+ score: sec.score,
164
+ band: sec.band
165
+ });
166
+ }
167
+
168
+ const globalByFamily = new Map();
169
+ for (const g of d.global || []) {
170
+ const fam = familyOf(g.prop);
171
+ if (!globalByFamily.has(fam)) globalByFamily.set(fam, []);
172
+ globalByFamily.get(fam).push(g);
173
+ }
174
+ for (const [fam, rows] of globalByFamily) {
175
+ const reach = rows.reduce((n, r) => Math.max(n, r.count), 0);
176
+ tasks.push({
177
+ kind: 'global',
178
+ family: fam,
179
+ title: `Set ${FAMILY_LABEL[fam] || fam} globally (affects up to ${reach} elements)`,
180
+ why: rows.map((r) => `${describeRow(r)} on ${r.count} elements`),
181
+ fixAt: fixHintFor(fam, null, 'global'),
182
+ rows,
183
+ affects: reach,
184
+ mayResolve: reach
185
+ });
186
+ }
187
+
188
+ const downstream = [];
189
+ const elementTasks = [];
190
+ for (const el of d.elements || []) {
191
+ let real = el.rows.filter((r) => !r.geometry);
192
+ const symptoms = el.rows.filter((r) => r.geometry);
193
+
194
+ const hasSize = real.some((r) => familyOf(r.prop) === 'size');
195
+ const ml = real.find((r) => r.prop === 'margin-left');
196
+ const mr = real.find((r) => r.prop === 'margin-right');
197
+ if (hasSize && ml && mr && ml.proto === mr.proto && ml.draft === mr.draft) {
198
+ real = real.filter((r) => r !== ml && r !== mr);
199
+ }
200
+ if (!real.length) {
201
+ if (symptoms.length) downstream.push({ key: el.key, label: el.label, symptoms });
202
+ continue;
203
+ }
204
+
205
+ const byFamily = new Map();
206
+ for (const row of real) {
207
+ const fam = familyOf(row.prop);
208
+ if (!byFamily.has(fam)) byFamily.set(fam, []);
209
+ byFamily.get(fam).push(row);
210
+ }
211
+ const ordered = [...byFamily.entries()].sort(
212
+ (a, b) => FAMILY_RANK.indexOf(a[0]) - FAMILY_RANK.indexOf(b[0])
213
+ );
214
+ let firstForElement = true;
215
+ for (const [fam, rows] of ordered) {
216
+ elementTasks.push({
217
+ kind: 'element',
218
+ family: fam,
219
+ role: el.role,
220
+ key: el.key,
221
+ label: el.label,
222
+ selector: el.draftSelector,
223
+ title: `${el.key}: correct the ${FAMILY_LABEL[fam] || fam}`,
224
+ why: rows.map(describeRow),
225
+ fixAt: fixHintFor(fam, el.role, 'element'),
226
+ rows,
227
+ evidence: firstForElement ? symptoms.map(describeRow) : [],
228
+ affects: 1,
229
+ mayResolve: 0
230
+ });
231
+ firstForElement = false;
232
+ }
233
+ }
234
+
235
+ elementTasks.sort((a, b) => roleRank(a.role) - roleRank(b.role) || a.key.localeCompare(b.key));
236
+ tasks.push(...elementTasks);
237
+
238
+ return { tasks, downstream };
239
+ }
240
+
241
+ export function renderPlan(plan, width) {
242
+ const out = [];
243
+ const { tasks, downstream } = plan;
244
+ if (!tasks.length) return out;
245
+
246
+ out.push('');
247
+ out.push('-'.repeat(64));
248
+ out.push(`WORK PLAN @ ${width}px — ${tasks.length} tasks, in this order`);
249
+ out.push('-'.repeat(64));
250
+ out.push('Do ONE task, re-measure, then continue. Order: structural → pixel (by severity)');
251
+ out.push('→ global styles → local styles. Pixel gate is authoritative for exit 0.');
252
+ out.push('');
253
+
254
+ tasks.forEach((t, i) => {
255
+ const tag = t.kind === 'structural' ? 'STRUCTURAL'
256
+ : t.kind === 'pixel' ? `PIXEL${t.severity ? ` ${t.severity}` : ''}`
257
+ : t.kind === 'global' ? 'GLOBAL' : 'LOCAL';
258
+ out.push(`TASK ${i + 1} [${tag}] ${t.title}`);
259
+ if (t.selector) out.push(` element: ${t.selector}${t.label ? ` ("${t.label}")` : ''}`);
260
+ if (t.ttRowId) out.push(` tt ids: ${t.ttRowId}${(t.ttBlockIds || []).length ? ` / ${t.ttBlockIds.join(', ')}` : ''}`);
261
+ out.push(' set:');
262
+ for (const w of t.why) out.push(` - ${w}`);
263
+ out.push(` fix at: ${t.fixAt}`);
264
+ if (t.mayResolve > 1) {
265
+ out.push(` note: may clear up to ${t.mayResolve} element rows — re-measure before doing local work.`);
266
+ }
267
+ if (t.evidence && t.evidence.length) {
268
+ out.push(` symptom: ${t.evidence.join('; ')} (should follow automatically)`);
269
+ }
270
+ out.push('');
271
+ });
272
+
273
+ if (downstream.length) {
274
+ out.push('DO NOT TOUCH — downstream only (geometry differs, but no property of');
275
+ out.push('their own is wrong; these follow from the tasks above):');
276
+ for (const dsn of downstream) {
277
+ out.push(` - ${dsn.key}: ${dsn.symptoms.map((s) => s.prop).join(', ')}`);
278
+ }
279
+ out.push('');
280
+ }
281
+
282
+ return out;
283
+ }
284
+
285
+ export function renderStyleEvidence(results, tol, plansOnly) {
286
+ const out = [];
287
+ out.push('STYLE EXPLAINER — computed styles (does NOT alone certify exit 0 in --mode full|pixel)');
288
+ out.push('Read as: property PROTOTYPE(target) -> DRAFT(current)');
289
+ out.push(`Tolerance: +/-${tol}px on lengths.`);
290
+ out.push('');
291
+
292
+ for (const r of results) {
293
+ const { width, diff: d } = r;
294
+ if (!d) continue;
295
+ out.push(`${'='.repeat(64)}`);
296
+ out.push(`VIEWPORT ${width}px — ${d.total} style findings${d.global?.length ? ` (${d.global.length} global)` : ''}`);
297
+ out.push(`${'='.repeat(64)}`);
298
+ if (r.coverage) {
299
+ const { renderCoverage } = awaitImportCoverage();
300
+ out.push(...renderCoverage(r.coverage));
301
+ }
302
+ out.push(...renderPlan(buildPlan(d, { pixelSections: r.pixelSections || [] }), width));
303
+ if (plansOnly) continue;
304
+
305
+ if (d.structural?.length) {
306
+ out.push('');
307
+ out.push('STRUCTURAL:');
308
+ for (const s of d.structural) out.push(` - ${s.text}`);
309
+ }
310
+ if (d.global?.length) {
311
+ out.push('');
312
+ out.push('GLOBAL / INHERITED:');
313
+ for (const g of d.global) {
314
+ out.push(` ${g.prop} ${g.proto} -> ${g.draft} (×${g.count})`);
315
+ }
316
+ }
317
+ for (const el of d.elements || []) {
318
+ out.push('');
319
+ out.push(`[${el.key}]${el.label ? ` "${el.label}"` : ''}`);
320
+ for (const row of el.rows) {
321
+ out.push(` ${row.prop} ${row.proto} -> ${row.draft}${row.geometry ? ' (symptom)' : ''}`);
322
+ }
323
+ }
324
+ out.push('');
325
+ }
326
+ return out.join('\n');
327
+ }
328
+
329
+ function awaitImportCoverage() {
330
+ // sync helper — coverage lines inlined to avoid circular import issues at render time
331
+ return {
332
+ renderCoverage(cov) {
333
+ const pct = Math.round(cov.ratio * 100);
334
+ const lines = [
335
+ `COVERAGE: ${cov.matched.length}/${cov.matched.length + cov.onlyProto.length + cov.onlyDraft.length} roles matched on both pages (${pct}%).`
336
+ ];
337
+ if (cov.onlyProto.length) lines.push(` matched on the prototype only: ${cov.onlyProto.join(', ')}`);
338
+ if (cov.onlyDraft.length) lines.push(` matched on the draft only: ${cov.onlyDraft.join(', ')}`);
339
+ return lines;
340
+ }
341
+ };
342
+ }
343
+
344
+ /**
345
+ * Human report for pixel-first runs.
346
+ */
347
+ export function renderPixelReport(pages, { mode }) {
348
+ const lines = [];
349
+ lines.push('PIXEL GATE — AUTHORITATIVE (section screenshots)');
350
+ lines.push(`Mode: ${mode}. Exit 0 only when every compared section is < clean threshold.`);
351
+ lines.push('"Matched" means measured. Do not self-certify parity.');
352
+ lines.push('');
353
+
354
+ for (const page of pages) {
355
+ lines.push(`${'='.repeat(64)}`);
356
+ lines.push(`PAGE ${page.id || page.proto} @ viewports ${page.viewports.map((v) => v.width).join(',')}`);
357
+ lines.push(`${'='.repeat(64)}`);
358
+ for (const vp of page.viewports) {
359
+ const secs = vp.sections || [];
360
+ const failing = secs.filter((s) => s.band !== 'clean');
361
+ lines.push(`@ ${vp.width}px — ${secs.length} sections, ${failing.length} failing`);
362
+ if (vp.cacheHeaders && Object.keys(vp.cacheHeaders).length) {
363
+ lines.push(` cache headers (draft): ${JSON.stringify(vp.cacheHeaders)}`);
364
+ }
365
+ for (const s of secs) {
366
+ const pct = (s.score * 100).toFixed(1);
367
+ const mark = s.band === 'clean' ? 'OK' : s.band.toUpperCase();
368
+ const id = s.ttRowId || `section:${s.index}`;
369
+ lines.push(` [${mark} ${pct}%] ${id} — "${s.label || ''}"`);
370
+ }
371
+ lines.push(...renderPlan(buildPlan(vp.styleDiff || { structural: [], global: [], elements: [], total: 0 }, { pixelSections: secs }), vp.width));
372
+ }
373
+ lines.push('');
374
+ }
375
+ return lines.join('\n');
376
+ }
377
+
378
+ export function renderQueueMd(clusters, pages) {
379
+ const lines = [];
380
+ lines.push('# Fidelity QUEUE');
381
+ lines.push('');
382
+ lines.push('Ranked by leverage. Do one task, re-measure, continue.');
383
+ lines.push('');
384
+ if (clusters?.length) {
385
+ lines.push('## Cross-page clusters (fix these first)');
386
+ lines.push('');
387
+ clusters.forEach((c, i) => {
388
+ lines.push(`${i + 1}. **${c.label || c.key}** — ${c.pages} pages, avg ${(c.avgScore * 100).toFixed(1)}% divergent`);
389
+ if (c.instancesPerPage != null) {
390
+ lines.push(` - instances/page: ${c.instancesPerPage.toFixed(2)} (≪1 ≈ page-specific; ≫1 ≈ safe default)`);
391
+ }
392
+ });
393
+ lines.push('');
394
+ }
395
+ lines.push('## Per-page summary');
396
+ lines.push('');
397
+ for (const page of pages) {
398
+ let fail = 0;
399
+ let total = 0;
400
+ for (const vp of page.viewports) {
401
+ for (const s of vp.sections || []) {
402
+ total += 1;
403
+ if (s.band !== 'clean') fail += 1;
404
+ }
405
+ }
406
+ lines.push(`- \`${page.id}\`: ${fail}/${total} sections failing`);
407
+ }
408
+ lines.push('');
409
+ return lines.join('\n');
410
+ }
package/src/rank.mjs ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Cross-page clustering by section heading / component label.
3
+ */
4
+
5
+ export function clusterSections(pages) {
6
+ const map = new Map();
7
+ for (const page of pages) {
8
+ const seenOnPage = new Set();
9
+ for (const vp of page.viewports || []) {
10
+ for (const sec of vp.sections || []) {
11
+ if (!sec.band || sec.band === 'clean') continue;
12
+ const key = normalizeLabel(sec.label) || sec.ttRowId || `idx:${sec.index}`;
13
+ if (!map.has(key)) {
14
+ map.set(key, {
15
+ key,
16
+ label: sec.label || key,
17
+ scores: [],
18
+ pageIds: new Set(),
19
+ instances: 0
20
+ });
21
+ }
22
+ const c = map.get(key);
23
+ c.scores.push(sec.score);
24
+ c.pageIds.add(page.id);
25
+ c.instances += 1;
26
+ seenOnPage.add(key);
27
+ }
28
+ }
29
+ }
30
+
31
+ const pageCount = Math.max(1, pages.length);
32
+ const clusters = [...map.values()]
33
+ .map((c) => ({
34
+ key: c.key,
35
+ label: c.label,
36
+ pages: c.pageIds.size,
37
+ avgScore: c.scores.reduce((a, b) => a + b, 0) / c.scores.length,
38
+ instances: c.instances,
39
+ instancesPerPage: c.instances / pageCount
40
+ }))
41
+ .sort((a, b) => b.pages - a.pages || b.avgScore - a.avgScore);
42
+
43
+ return clusters;
44
+ }
45
+
46
+ function normalizeLabel(label) {
47
+ return String(label || '')
48
+ .toLowerCase()
49
+ .replace(/\s+/g, ' ')
50
+ .trim()
51
+ .slice(0, 60);
52
+ }