@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/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
|
+
}
|