@mmerterden/dev-toolkit-mcp 2.24.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/CHANGELOG.md +708 -0
- package/LICENSE +21 -0
- package/README.md +387 -0
- package/index.js +1277 -0
- package/package.json +80 -0
- package/tools/design-check/content-cardinality.js +204 -0
- package/tools/design-check/geometry.js +140 -0
- package/tools/design-check/index.js +213 -0
- package/tools/design-check/mock-detect.js +213 -0
- package/tools/design-check/report.js +590 -0
- package/tools/design-check/scan.js +91 -0
- package/tools/design-check/scenario-inventory.js +598 -0
- package/tools/design-check/visual-compare.js +961 -0
- package/tools/ios-app-store-audit/context.js +180 -0
- package/tools/ios-app-store-audit/data/apple-required-sdks.json +32 -0
- package/tools/ios-app-store-audit/data/debug-tools-blocklist.json +133 -0
- package/tools/ios-app-store-audit/index.js +164 -0
- package/tools/ios-app-store-audit/models.js +47 -0
- package/tools/ios-app-store-audit/rules/asset-validation.js +72 -0
- package/tools/ios-app-store-audit/rules/binary-size.js +70 -0
- package/tools/ios-app-store-audit/rules/code-signing.js +95 -0
- package/tools/ios-app-store-audit/rules/dead-reference.js +131 -0
- package/tools/ios-app-store-audit/rules/debug-tool-leak.js +185 -0
- package/tools/ios-app-store-audit/rules/duplicate-resource.js +130 -0
- package/tools/ios-app-store-audit/rules/embedded-sdk.js +126 -0
- package/tools/ios-app-store-audit/rules/entitlement.js +105 -0
- package/tools/ios-app-store-audit/rules/extension-signing.js +105 -0
- package/tools/ios-app-store-audit/rules/info-plist.js +158 -0
- package/tools/ios-app-store-audit/rules/ipv6-compliance.js +101 -0
- package/tools/ios-app-store-audit/rules/privacy-manifest.js +121 -0
- package/tools/ios-app-store-audit/rules/production-hygiene.js +237 -0
- package/tools/ios-app-store-audit/rules/provisioning-profile.js +127 -0
- package/tools/ios-app-store-audit/rules/required-reason-api.js +123 -0
- package/tools/ios-app-store-audit/rules/sdk-floor.js +104 -0
- package/tools/ios-app-store-audit/rules/swift-abi.js +64 -0
- package/tools/ios-app-store-audit/rules/team-id.js +62 -0
- package/tools/ios-testflight/index.js +479 -0
- package/ui-tree-dumper.swift +122 -0
|
@@ -0,0 +1,961 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* design_visual_compare - generalized visual comparison of a Figma render
|
|
3
|
+
* against a live device screenshot. Unlike ios_visual_diff it does NOT fail on
|
|
4
|
+
* size mismatch: it normalizes scale and (optionally) crops device chrome, then
|
|
5
|
+
* reports three finding families:
|
|
6
|
+
* - pixel : perceptual diff percentage (pixelmatch)
|
|
7
|
+
* - spacing/size/position : px deltas between matched Figma vs live elements
|
|
8
|
+
* - color : ΔE between the Figma fill and the sampled live pixels
|
|
9
|
+
* - typography: expected font size/family from the Figma spec (advisory)
|
|
10
|
+
*
|
|
11
|
+
* Pure JS (pngjs + pixelmatch, both already deps). No native image libs.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync, writeFileSync } from "fs";
|
|
15
|
+
import { detectCardinalityMismatch, demoteCardinalityNoise } from "./content-cardinality.js";
|
|
16
|
+
import { join } from "path";
|
|
17
|
+
|
|
18
|
+
let PNG, pixelmatch;
|
|
19
|
+
async function ensureLibs() {
|
|
20
|
+
if (PNG && pixelmatch) return;
|
|
21
|
+
({ PNG } = await import("pngjs"));
|
|
22
|
+
pixelmatch = (await import("pixelmatch")).default;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readPng(path) { return PNG.sync.read(readFileSync(path)); }
|
|
26
|
+
|
|
27
|
+
function cropTop(png, px) {
|
|
28
|
+
if (!px || px <= 0) return png;
|
|
29
|
+
const h = Math.max(1, png.height - px);
|
|
30
|
+
const out = new PNG({ width: png.width, height: h });
|
|
31
|
+
for (let y = 0; y < h; y++) {
|
|
32
|
+
const s = ((y + px) * png.width) * 4;
|
|
33
|
+
const d = (y * png.width) * 4;
|
|
34
|
+
png.data.copy(out.data, d, s, s + png.width * 4);
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Crop an arbitrary rectangle, in PIXELS, clamped to the image.
|
|
40
|
+
function cropRect(png, r) {
|
|
41
|
+
const x = Math.max(0, Math.min(png.width - 1, Math.round(r.x)));
|
|
42
|
+
const y = Math.max(0, Math.min(png.height - 1, Math.round(r.y)));
|
|
43
|
+
const w = Math.max(1, Math.min(png.width - x, Math.round(r.w)));
|
|
44
|
+
const h = Math.max(1, Math.min(png.height - y, Math.round(r.h)));
|
|
45
|
+
const out = new PNG({ width: w, height: h });
|
|
46
|
+
for (let row = 0; row < h; row++) {
|
|
47
|
+
const s = ((y + row) * png.width + x) * 4;
|
|
48
|
+
const d = (row * w) * 4;
|
|
49
|
+
png.data.copy(out.data, d, s, s + w * 4);
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Bilinear resize of an RGBA PNG to (dw, dh).
|
|
55
|
+
function resize(png, dw, dh) {
|
|
56
|
+
const out = new PNG({ width: dw, height: dh });
|
|
57
|
+
const { width: sw, height: sh, data: src } = png;
|
|
58
|
+
const xr = sw / dw, yr = sh / dh;
|
|
59
|
+
for (let y = 0; y < dh; y++) {
|
|
60
|
+
const sy = Math.min(sh - 1, y * yr);
|
|
61
|
+
const y0 = Math.floor(sy), y1 = Math.min(sh - 1, y0 + 1), wy = sy - y0;
|
|
62
|
+
for (let x = 0; x < dw; x++) {
|
|
63
|
+
const sx = Math.min(sw - 1, x * xr);
|
|
64
|
+
const x0 = Math.floor(sx), x1 = Math.min(sw - 1, x0 + 1), wx = sx - x0;
|
|
65
|
+
const di = (y * dw + x) * 4;
|
|
66
|
+
for (let c = 0; c < 4; c++) {
|
|
67
|
+
const p00 = src[(y0 * sw + x0) * 4 + c], p10 = src[(y0 * sw + x1) * 4 + c];
|
|
68
|
+
const p01 = src[(y1 * sw + x0) * 4 + c], p11 = src[(y1 * sw + x1) * 4 + c];
|
|
69
|
+
const top = p00 + (p10 - p00) * wx, bot = p01 + (p11 - p01) * wx;
|
|
70
|
+
out.data[di + c] = Math.round(top + (bot - top) * wy);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function drawRect(png, x, y, w, h, [r, g, b], thickness = 3) {
|
|
78
|
+
const { width, height, data } = png;
|
|
79
|
+
const put = (px, py) => {
|
|
80
|
+
if (px < 0 || py < 0 || px >= width || py >= height) return;
|
|
81
|
+
const i = (py * width + px) * 4;
|
|
82
|
+
data[i] = r; data[i + 1] = g; data[i + 2] = b; data[i + 3] = 255;
|
|
83
|
+
};
|
|
84
|
+
for (let t = 0; t < thickness; t++) {
|
|
85
|
+
for (let px = x; px < x + w; px++) { put(px, y + t); put(px, y + h - 1 - t); }
|
|
86
|
+
for (let py = y; py < y + h; py++) { put(x + t, py); put(x + w - 1 - t, py); }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function sampleColor(png, x, y, w, h) {
|
|
91
|
+
const { width, height, data } = png;
|
|
92
|
+
let r = 0, g = 0, b = 0, n = 0;
|
|
93
|
+
const x0 = Math.max(0, x), y0 = Math.max(0, y);
|
|
94
|
+
const x1 = Math.min(width, x + w), y1 = Math.min(height, y + h);
|
|
95
|
+
const step = Math.max(1, Math.floor(Math.min(x1 - x0, y1 - y0) / 12));
|
|
96
|
+
for (let py = y0; py < y1; py += step) {
|
|
97
|
+
for (let px = x0; px < x1; px += step) {
|
|
98
|
+
const i = (py * width + px) * 4;
|
|
99
|
+
if (data[i + 3] < 128) continue;
|
|
100
|
+
r += data[i]; g += data[i + 1]; b += data[i + 2]; n++;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (!n) return null;
|
|
104
|
+
return { r: r / n, g: g / n, b: b / n };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Text colour cannot be read from a box average: a label's box is mostly
|
|
108
|
+
// background, so the mean returns the background and every font-colour check
|
|
109
|
+
// silently passes. Instead take the ink - the pixels furthest in luminance from
|
|
110
|
+
// the box's dominant (background) tone - and average only those.
|
|
111
|
+
function sampleInkColor(png, x, y, w, h) {
|
|
112
|
+
const { width, height, data } = png;
|
|
113
|
+
const x0 = Math.max(0, x), y0 = Math.max(0, y);
|
|
114
|
+
const x1 = Math.min(width, x + w), y1 = Math.min(height, y + h);
|
|
115
|
+
if (x1 <= x0 || y1 <= y0) return null;
|
|
116
|
+
const px = [];
|
|
117
|
+
for (let py = y0; py < y1; py++) {
|
|
118
|
+
for (let pxx = x0; pxx < x1; pxx++) {
|
|
119
|
+
const i = (py * width + pxx) * 4;
|
|
120
|
+
if (data[i + 3] < 128) continue;
|
|
121
|
+
const R = data[i], G = data[i + 1], B = data[i + 2];
|
|
122
|
+
px.push([R, G, B, 0.2126 * R + 0.7152 * G + 0.0722 * B]);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (px.length < 8) return null;
|
|
126
|
+
// Dominant tone = median luminance (the background fills most of the box).
|
|
127
|
+
const lums = px.map((p) => p[3]).sort((a, b) => a - b);
|
|
128
|
+
const bg = lums[Math.floor(lums.length / 2)];
|
|
129
|
+
// Ink = the decile furthest from the background tone.
|
|
130
|
+
const scored = px.map((p) => [p, Math.abs(p[3] - bg)]).sort((a, b) => b[1] - a[1]);
|
|
131
|
+
const take = Math.max(4, Math.floor(scored.length * 0.1));
|
|
132
|
+
let r = 0, g = 0, b = 0;
|
|
133
|
+
for (let k = 0; k < take; k++) { r += scored[k][0][0]; g += scored[k][0][1]; b += scored[k][0][2]; }
|
|
134
|
+
const ink = { r: r / take, g: g / take, b: b / take };
|
|
135
|
+
const contrast = Math.abs((0.2126 * ink.r + 0.7152 * ink.g + 0.0722 * ink.b) - bg);
|
|
136
|
+
// Too little separation means the box holds no readable ink (icon-only, image,
|
|
137
|
+
// or an empty container) - reporting a colour there would be a guess.
|
|
138
|
+
return contrast < 12 ? null : ink;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Where a full-width row visually starts and ends, measured from the PIXELS.
|
|
143
|
+
*
|
|
144
|
+
* A Figma node tree reports the INSTANCE wrapper (often full-bleed, x=0
|
|
145
|
+
* w=frameWidth) while an accessibility tree reports the VISIBLE card inside it.
|
|
146
|
+
* Comparing one against the other manufactures an inset finding on a screen that
|
|
147
|
+
* is actually correct - the single largest source of false positives in this
|
|
148
|
+
* audit. Reading both sides off their own renders removes the mismatch: whatever
|
|
149
|
+
* the node tree claims, the pixels agree on where the card's edge is.
|
|
150
|
+
*
|
|
151
|
+
* Returns { left, right } in frame POINTS, or null when the row is uniform
|
|
152
|
+
* (nothing drawn on it) and there is no edge to find.
|
|
153
|
+
*/
|
|
154
|
+
function visualRowInsets(png, yPt, frameWPt, frameHPt, tol = 18) {
|
|
155
|
+
const { width: W, height: H, data } = png;
|
|
156
|
+
const y = Math.round(yPt * (H / frameHPt));
|
|
157
|
+
if (y < 0 || y >= H) return null;
|
|
158
|
+
const at = (x) => { const i = (y * W + x) * 4; return [data[i], data[i + 1], data[i + 2]]; };
|
|
159
|
+
const bg = at(Math.min(2, W - 1));
|
|
160
|
+
const differs = (c) => Math.abs(c[0] - bg[0]) + Math.abs(c[1] - bg[1]) + Math.abs(c[2] - bg[2]) > tol;
|
|
161
|
+
let L = -1, R = -1;
|
|
162
|
+
for (let x = 0; x < W; x++) if (differs(at(x))) { L = x; break; }
|
|
163
|
+
if (L < 0) return null;
|
|
164
|
+
for (let x = W - 1; x >= 0; x--) if (differs(at(x))) { R = x; break; }
|
|
165
|
+
const sx = W / frameWPt;
|
|
166
|
+
return { left: L / sx, right: (W - 1 - R) / sx };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The four distances from a box to whatever actually bounds it - the same thing
|
|
171
|
+
* an on-device layout inspector shows when you select an element ("57pt above,
|
|
172
|
+
* 89pt below, 12pt each side").
|
|
173
|
+
*
|
|
174
|
+
* For each direction: the nearest sibling that overlaps on the perpendicular
|
|
175
|
+
* axis, else the frame edge. This is the measurement that answers "is it built
|
|
176
|
+
* 1:1", because it is LOCAL: it does not change when the mock fixture adds a row
|
|
177
|
+
* somewhere else on the screen, which is exactly what made absolute Y useless.
|
|
178
|
+
*
|
|
179
|
+
* Pure geometry over a flat box list, so the identical function runs on the
|
|
180
|
+
* design spec and on the live geometry - no per-project knowledge, and nothing
|
|
181
|
+
* that needs to run inside the app.
|
|
182
|
+
*/
|
|
183
|
+
function edgeContext(box, all, frameW, frameH) {
|
|
184
|
+
const b = { l: box.x, r: box.x + box.w, t: box.y, b: box.y + box.h };
|
|
185
|
+
const others = all.filter((o) => o !== box);
|
|
186
|
+
const vOverlap = (o) => Math.min(b.b, o.y + o.h) - Math.max(b.t, o.y) > 1;
|
|
187
|
+
const hOverlap = (o) => Math.min(b.r, o.x + o.w) - Math.max(b.l, o.x) > 1;
|
|
188
|
+
|
|
189
|
+
let left = b.l, leftRef = "frame";
|
|
190
|
+
for (const o of others) {
|
|
191
|
+
if (!vOverlap(o)) continue;
|
|
192
|
+
const or = o.x + o.w;
|
|
193
|
+
if (or <= b.l + 1) { const d = b.l - or; if (d < left) { left = d; leftRef = o.label || "sibling"; } }
|
|
194
|
+
}
|
|
195
|
+
let right = frameW - b.r, rightRef = "frame";
|
|
196
|
+
for (const o of others) {
|
|
197
|
+
if (!vOverlap(o)) continue;
|
|
198
|
+
if (o.x >= b.r - 1) { const d = o.x - b.r; if (d < right) { right = d; rightRef = o.label || "sibling"; } }
|
|
199
|
+
}
|
|
200
|
+
let top = b.t, topRef = "frame";
|
|
201
|
+
for (const o of others) {
|
|
202
|
+
if (!hOverlap(o)) continue;
|
|
203
|
+
const ob = o.y + o.h;
|
|
204
|
+
if (ob <= b.t + 1) { const d = b.t - ob; if (d < top) { top = d; topRef = o.label || "sibling"; } }
|
|
205
|
+
}
|
|
206
|
+
let bottom = frameH != null ? frameH - b.b : Infinity, bottomRef = "frame";
|
|
207
|
+
for (const o of others) {
|
|
208
|
+
if (!hOverlap(o)) continue;
|
|
209
|
+
if (o.y >= b.b - 1) { const d = o.y - b.b; if (d < bottom) { bottom = d; bottomRef = o.label || "sibling"; } }
|
|
210
|
+
}
|
|
211
|
+
return { left, right, top, bottom, leftRef, rightRef, topRef, bottomRef };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Copy conformance against the design's source-of-truth UX writing.
|
|
216
|
+
*
|
|
217
|
+
* A frame render is a stale picture of the words; the authoritative strings are
|
|
218
|
+
* the design's UX-writing annotations. Comparing against those lets a real copy
|
|
219
|
+
* defect ("Soyad" where the design says "Soyadı") be reported as a defect, rather
|
|
220
|
+
* than being lost in a perceptual diff that only says "these pixels differ".
|
|
221
|
+
*
|
|
222
|
+
* Matching is deliberately asymmetric. An EXACT live match verifies the string. A
|
|
223
|
+
* NEAR match is the interesting case: a near miss is almost always a truncated or
|
|
224
|
+
* stale translation rather than an unrelated element. A string with no plausible
|
|
225
|
+
* counterpart is reported as absent and advisory, because this capture may simply
|
|
226
|
+
* not show the state or scroll position that carries it - never silently dropped.
|
|
227
|
+
*/
|
|
228
|
+
function normCopy(s) {
|
|
229
|
+
return (s || "").toLowerCase()
|
|
230
|
+
.replace(/[‘’']/g, "'").replace(/[“”]/g, '"')
|
|
231
|
+
.replace(/\s+/g, " ").trim();
|
|
232
|
+
}
|
|
233
|
+
function similarity(a, b) {
|
|
234
|
+
a = normCopy(a); b = normCopy(b);
|
|
235
|
+
if (!a || !b) return 0;
|
|
236
|
+
if (a === b) return 1;
|
|
237
|
+
// Token overlap plus prefix credit: enough to tell a truncated string from an
|
|
238
|
+
// unrelated one without pulling in an edit-distance dependency.
|
|
239
|
+
const at = new Set(a.split(" ")), bt = new Set(b.split(" "));
|
|
240
|
+
let shared = 0;
|
|
241
|
+
for (const t of at) if (bt.has(t)) shared++;
|
|
242
|
+
const overlap = shared / Math.max(at.size, bt.size);
|
|
243
|
+
const longer = a.length >= b.length ? a : b;
|
|
244
|
+
const shorter = a.length >= b.length ? b : a;
|
|
245
|
+
const prefix = longer.startsWith(shorter) ? shorter.length / longer.length : 0;
|
|
246
|
+
return Math.max(overlap, prefix);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function compareCopy(designStrings, liveTexts, { nearThreshold = 0.75 } = {}) {
|
|
250
|
+
const findings = [];
|
|
251
|
+
const live = (liveTexts || []).filter(Boolean);
|
|
252
|
+
const wants = (designStrings || []).filter(Boolean);
|
|
253
|
+
|
|
254
|
+
// One live string answers ONE design string. Without that, a single label can be
|
|
255
|
+
// offered as the near-match for several unrelated design strings, and a loose
|
|
256
|
+
// threshold then turns "not on this screen" into a fabricated copy defect.
|
|
257
|
+
// Exact matches claim their counterpart first, then near matches take what is
|
|
258
|
+
// left, strongest pair first.
|
|
259
|
+
const claimed = new Set();
|
|
260
|
+
const exact = new Map();
|
|
261
|
+
wants.forEach((want) => {
|
|
262
|
+
const i = live.findIndex((l, idx) => !claimed.has(idx) && normCopy(l) === normCopy(want));
|
|
263
|
+
if (i >= 0) { claimed.add(i); exact.set(want, live[i]); }
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const candidates = [];
|
|
267
|
+
for (const want of wants) {
|
|
268
|
+
if (exact.has(want)) continue;
|
|
269
|
+
live.forEach((l, idx) => {
|
|
270
|
+
if (claimed.has(idx)) return;
|
|
271
|
+
const sc = similarity(want, l);
|
|
272
|
+
if (sc >= nearThreshold) candidates.push({ want, l, idx, sc });
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
candidates.sort((a, b) => b.sc - a.sc);
|
|
276
|
+
const near = new Map();
|
|
277
|
+
for (const c of candidates) {
|
|
278
|
+
if (near.has(c.want) || claimed.has(c.idx)) continue;
|
|
279
|
+
near.set(c.want, { text: c.l, sc: c.sc });
|
|
280
|
+
claimed.add(c.idx);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
for (const want of wants) {
|
|
284
|
+
if (exact.has(want)) {
|
|
285
|
+
findings.push({
|
|
286
|
+
type: "copy", category: "localization", status: "verified",
|
|
287
|
+
element: want, detail: "on-spec", expected: want, actual: want,
|
|
288
|
+
});
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
const hit = near.get(want);
|
|
292
|
+
const best = hit && hit.text, bestScore = hit ? hit.sc : 0;
|
|
293
|
+
if (best) {
|
|
294
|
+
findings.push({
|
|
295
|
+
type: "copy", category: "localization", element: want,
|
|
296
|
+
detail: `design UX writing "${want}" renders as "${best}"`,
|
|
297
|
+
expected: want, actual: best, similarity: Number(bestScore.toFixed(2)),
|
|
298
|
+
});
|
|
299
|
+
} else {
|
|
300
|
+
findings.push({
|
|
301
|
+
type: "copy", category: "localization", element: want, advisory: true,
|
|
302
|
+
detail: `design UX writing "${want}" not found on this screen - advisory: this capture may not show the state or scroll position that carries it`,
|
|
303
|
+
expected: want, actual: "(not on screen)",
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return findings;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function hexToRgb(hex) {
|
|
311
|
+
const m = /^#?([0-9a-f]{6})$/i.exec(hex || "");
|
|
312
|
+
if (!m) return null;
|
|
313
|
+
const v = parseInt(m[1], 16);
|
|
314
|
+
return { r: (v >> 16) & 255, g: (v >> 8) & 255, b: v & 255 };
|
|
315
|
+
}
|
|
316
|
+
function rgbToHex({ r, g, b }) {
|
|
317
|
+
const h = (v) => Math.round(v).toString(16).padStart(2, "0");
|
|
318
|
+
return `#${h(r)}${h(g)}${h(b)}`.toUpperCase();
|
|
319
|
+
}
|
|
320
|
+
function srgbToLin(c) { c /= 255; return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }
|
|
321
|
+
function rgbToLab({ r, g, b }) {
|
|
322
|
+
const R = srgbToLin(r), G = srgbToLin(g), B = srgbToLin(b);
|
|
323
|
+
let x = (R * 0.4124 + G * 0.3576 + B * 0.1805) / 0.95047;
|
|
324
|
+
let y = (R * 0.2126 + G * 0.7152 + B * 0.0722);
|
|
325
|
+
let z = (R * 0.0193 + G * 0.1192 + B * 0.9505) / 1.08883;
|
|
326
|
+
const f = (t) => t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116;
|
|
327
|
+
x = f(x); y = f(y); z = f(z);
|
|
328
|
+
return [116 * y - 16, 500 * (x - y), 200 * (y - z)];
|
|
329
|
+
}
|
|
330
|
+
function deltaE(c1, c2) {
|
|
331
|
+
const a = rgbToLab(c1), b = rgbToLab(c2);
|
|
332
|
+
return Math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function norm(s) { return (s || "").trim().toLowerCase().replace(/\s+/g, " "); }
|
|
336
|
+
|
|
337
|
+
// Figma spec fields arrive under a few spellings depending on how the caller
|
|
338
|
+
// flattened the node tree.
|
|
339
|
+
function fe_fontSize(m) {
|
|
340
|
+
const fe = m.fe || {};
|
|
341
|
+
return fe.fontSize || fe.font_size || (fe.style && fe.style.fontSize) || null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Match figma spec elements to live geometry by text/label, else skip.
|
|
345
|
+
function autoPair(figmaSpec, liveGeom) {
|
|
346
|
+
const pairs = [];
|
|
347
|
+
const used = new Set();
|
|
348
|
+
const liveByText = new Map();
|
|
349
|
+
liveGeom.forEach((el, i) => {
|
|
350
|
+
const k = norm(el.text || el.label);
|
|
351
|
+
if (k && !liveByText.has(k)) liveByText.set(k, i);
|
|
352
|
+
});
|
|
353
|
+
for (const fe of figmaSpec) {
|
|
354
|
+
const k = norm(fe.text || fe.label);
|
|
355
|
+
if (!k) continue;
|
|
356
|
+
const i = liveByText.get(k);
|
|
357
|
+
if (i !== undefined && !used.has(i)) { used.add(i); pairs.push({ figma: fe, live: liveGeom[i] }); }
|
|
358
|
+
}
|
|
359
|
+
return pairs;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export async function compareVisual(opts) {
|
|
363
|
+
await ensureLibs();
|
|
364
|
+
const {
|
|
365
|
+
figmaPng, livePng, outDir,
|
|
366
|
+
cropTopLive = 0, cropTopFigma = 0,
|
|
367
|
+
figmaSpec = [], liveGeometry = [], pairs: givenPairs = null,
|
|
368
|
+
figmaFrame = null, liveScreen = null,
|
|
369
|
+
liveRegion = null, expectedRegion = null,
|
|
370
|
+
tolerancePx = 2, colorTolerance = 3, perceptualPrecision = 0.1, maxDiffPct = 1.0,
|
|
371
|
+
// Measure edge insets + inter-element gaps in points instead of raw
|
|
372
|
+
// width/height in a stretched compare space. On by default: a device and a
|
|
373
|
+
// Figma frame of different widths make raw size deltas meaningless.
|
|
374
|
+
responsive = true,
|
|
375
|
+
relations = true,
|
|
376
|
+
edges = true,
|
|
377
|
+
designCopy = null,
|
|
378
|
+
tolerancePt = null,
|
|
379
|
+
verifyFontFamily = false,
|
|
380
|
+
contentCardinality = true,
|
|
381
|
+
label = "screen",
|
|
382
|
+
} = opts;
|
|
383
|
+
|
|
384
|
+
let figma = readPng(figmaPng);
|
|
385
|
+
let live = readPng(livePng);
|
|
386
|
+
const rawLiveW = live.width, rawLiveH = live.height; // before crop, for unit conversion
|
|
387
|
+
if (cropTopFigma) figma = cropTop(figma, cropTopFigma);
|
|
388
|
+
|
|
389
|
+
// A bottom sheet / modal / partial overlay has a Figma frame covering only the
|
|
390
|
+
// sheet, while the live capture is the whole screen. Stretching the full screen
|
|
391
|
+
// onto a sheet-shaped frame misplaces every element inside it, so the deltas
|
|
392
|
+
// become noise and a real edge inset hides in it. Cropping the live capture to
|
|
393
|
+
// the sheet's own bounds first makes the two comparable, and then a side inset
|
|
394
|
+
// is a plain width/position delta.
|
|
395
|
+
//
|
|
396
|
+
// liveRegion is in liveScreen units (iOS points / Android px); the PNG is in
|
|
397
|
+
// device pixels, hence the density conversion.
|
|
398
|
+
let regionOrigin = { x: 0, y: 0 };
|
|
399
|
+
let regionSize = null;
|
|
400
|
+
if (liveRegion) {
|
|
401
|
+
const dx = (liveScreen && liveScreen.w > 0) ? rawLiveW / liveScreen.w : 1;
|
|
402
|
+
const dy = (liveScreen && liveScreen.h > 0) ? rawLiveH / liveScreen.h : 1;
|
|
403
|
+
live = cropRect(live, {
|
|
404
|
+
x: liveRegion.x * dx, y: liveRegion.y * dy,
|
|
405
|
+
w: liveRegion.w * dx, h: liveRegion.h * dy,
|
|
406
|
+
});
|
|
407
|
+
regionOrigin = { x: liveRegion.x, y: liveRegion.y };
|
|
408
|
+
regionSize = { w: liveRegion.w, h: liveRegion.h };
|
|
409
|
+
} else if (cropTopLive) {
|
|
410
|
+
live = cropTop(live, cropTopLive);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const W = figma.width, H = figma.height; // compare in Figma render space
|
|
414
|
+
const liveResized = resize(live, W, H);
|
|
415
|
+
|
|
416
|
+
// Pixel diff
|
|
417
|
+
const diff = new PNG({ width: W, height: H });
|
|
418
|
+
const diffPixels = pixelmatch(figma.data, liveResized.data, diff.data, W, H, {
|
|
419
|
+
threshold: perceptualPrecision, includeAA: false,
|
|
420
|
+
});
|
|
421
|
+
const perceptualPct = parseFloat(((diffPixels / (W * H)) * 100).toFixed(4));
|
|
422
|
+
|
|
423
|
+
// Coordinate scales into the compare (Figma render) space.
|
|
424
|
+
// liveScreen + geometry are in the platform's native unit (iOS points, Android px),
|
|
425
|
+
// but cropTopLive is in live-screenshot PIXELS - convert it to liveScreen units first
|
|
426
|
+
// so the y-axis math never mixes points and pixels.
|
|
427
|
+
const cropUnits = liveRegion
|
|
428
|
+
? 0 // the region crop already excluded chrome
|
|
429
|
+
: ((liveScreen && rawLiveH > 0) ? cropTopLive * (liveScreen.h / rawLiveH) : cropTopLive);
|
|
430
|
+
const fScale = (figmaFrame && figmaFrame.w > 0) ? W / figmaFrame.w : 1;
|
|
431
|
+
// With a region, live coordinates are rebased onto the region's own box; without
|
|
432
|
+
// one they map from the whole (top-cropped) screen.
|
|
433
|
+
const lSpanX = regionSize ? regionSize.w : (liveScreen ? liveScreen.w : 0);
|
|
434
|
+
const lSpanY = regionSize ? regionSize.h : (liveScreen ? liveScreen.h - cropUnits : 0);
|
|
435
|
+
const lScaleX = lSpanX > 0 ? W / lSpanX : 1;
|
|
436
|
+
const lScaleY = lSpanY > 0 ? H / lSpanY : 1;
|
|
437
|
+
|
|
438
|
+
const findings = [];
|
|
439
|
+
|
|
440
|
+
// The sheet's own edge insets. Inside a region-aligned comparison the sheet fills
|
|
441
|
+
// the frame by construction, so a gap between the sheet and the screen edge can
|
|
442
|
+
// only be caught here - and a design that shows the sheet flush is not satisfied
|
|
443
|
+
// by one that floats inset from the edges.
|
|
444
|
+
if (liveRegion && expectedRegion) {
|
|
445
|
+
const gaps = {
|
|
446
|
+
left: Math.round(liveRegion.x - expectedRegion.x),
|
|
447
|
+
right: Math.round((expectedRegion.x + expectedRegion.w) - (liveRegion.x + liveRegion.w)),
|
|
448
|
+
top: Math.round(liveRegion.y - expectedRegion.y),
|
|
449
|
+
bottom: Math.round((expectedRegion.y + expectedRegion.h) - (liveRegion.y + liveRegion.h)),
|
|
450
|
+
};
|
|
451
|
+
for (const [edge, d] of Object.entries(gaps)) {
|
|
452
|
+
if (Math.abs(d) > tolerancePx) {
|
|
453
|
+
findings.push({
|
|
454
|
+
type: "inset", category: "padding", element: `${label} container`,
|
|
455
|
+
region: { x: 0, y: 0, w: W, h: H },
|
|
456
|
+
detail: `${edge} edge inset off by ${d >= 0 ? "+" : ""}${d}pt`,
|
|
457
|
+
expected: `${edge} ${edge === "left" || edge === "top" ? expectedRegion[edge === "left" ? "x" : "y"] : 0}pt`,
|
|
458
|
+
actual: `${edge} ${edge === "left" ? liveRegion.x : edge === "top" ? liveRegion.y : Math.abs(d)}pt`,
|
|
459
|
+
deltaPx: { [edge]: d },
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
const pairs = givenPairs && givenPairs.length ? givenPairs : autoPair(figmaSpec, liveGeometry);
|
|
465
|
+
|
|
466
|
+
const overlay = new PNG({ width: W, height: H });
|
|
467
|
+
liveResized.data.copy(overlay.data);
|
|
468
|
+
|
|
469
|
+
const RED = [227, 24, 55], AMBER = [230, 160, 20];
|
|
470
|
+
|
|
471
|
+
// ── Responsive measurement basis ────────────────────────────────────────────
|
|
472
|
+
// The device and the Figma frame are rarely the same width (e.g. 402pt vs
|
|
473
|
+
// 375pt). Comparing raw widths across them manufactures deltas: a full-bleed
|
|
474
|
+
// 375pt design container vs a 402pt-wide screen's 370pt card reads as "-29px
|
|
475
|
+
// too narrow" when nothing is wrong. Worse, a container whose height follows
|
|
476
|
+
// its content turns every fixture difference into a "size" finding.
|
|
477
|
+
//
|
|
478
|
+
// So measure what is actually device-independent and what a designer specs:
|
|
479
|
+
// - edge insets (left / right) in points
|
|
480
|
+
// - the gaps BETWEEN consecutive elements in points
|
|
481
|
+
// - typography (size / family / colour)
|
|
482
|
+
// A 16pt margin is 16pt on both sides of the comparison, whatever the frame
|
|
483
|
+
// width. Raw width/height stay available but as clearly-labelled advisories.
|
|
484
|
+
const designW = figmaFrame && figmaFrame.w > 0 ? figmaFrame.w : null;
|
|
485
|
+
const liveW = regionSize ? regionSize.w : (liveScreen && liveScreen.w > 0 ? liveScreen.w : null);
|
|
486
|
+
const canResolveInsets = responsive && designW && liveW;
|
|
487
|
+
const tolPt = tolerancePt != null ? tolerancePt : tolerancePx;
|
|
488
|
+
|
|
489
|
+
// Point-space geometry per pair, plus the compare-space box used for drawing.
|
|
490
|
+
const measured = pairs.filter((p) => p && p.figma && p.live).map(({ figma: fe, live: le }) => {
|
|
491
|
+
const d = { x: fe.x, y: fe.y, w: fe.w, h: fe.h };
|
|
492
|
+
const l = {
|
|
493
|
+
x: le.x - regionOrigin.x,
|
|
494
|
+
y: le.y - regionOrigin.y - cropUnits,
|
|
495
|
+
w: le.w, h: le.h,
|
|
496
|
+
};
|
|
497
|
+
return {
|
|
498
|
+
fe, le, d, l,
|
|
499
|
+
name: fe.label || fe.text || fe.identifier || "element",
|
|
500
|
+
box: {
|
|
501
|
+
x: Math.round(l.x * lScaleX), y: Math.round(l.y * lScaleY),
|
|
502
|
+
w: Math.round(l.w * lScaleX), h: Math.round(l.h * lScaleY),
|
|
503
|
+
},
|
|
504
|
+
};
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
// Resolve each full-width row's real edges from the pixels ONCE, and let every
|
|
508
|
+
// check read the same answer. Doing it per-check is how the inset check ended
|
|
509
|
+
// up pixel-correct while the centring and symmetry checks stayed fooled by the
|
|
510
|
+
// same wrapper-vs-visible mismatch, reporting a phantom on a correct screen.
|
|
511
|
+
// A stretching element's width is device-dependent, so its horizontal insets
|
|
512
|
+
// are only meaningful against its PARENT, never the frame: a button 16pt inside
|
|
513
|
+
// a modal keeps 16pt on any screen width, while its distance to the frame edge
|
|
514
|
+
// changes with both the device and the modal's own width. Reference the frame
|
|
515
|
+
// and a perfectly on-spec button reads as off.
|
|
516
|
+
//
|
|
517
|
+
// Parenthood is recovered geometrically - the smallest box that fully contains
|
|
518
|
+
// this one - so it needs no project knowledge and works on either side.
|
|
519
|
+
const containerOf = (box, all) => {
|
|
520
|
+
let best = null;
|
|
521
|
+
for (const o of all) {
|
|
522
|
+
if (o === box) continue;
|
|
523
|
+
const contains = o.x <= box.x + 1 && o.y <= box.y + 1 &&
|
|
524
|
+
o.x + o.w >= box.x + box.w - 1 && o.y + o.h >= box.y + box.h - 1;
|
|
525
|
+
if (!contains) continue;
|
|
526
|
+
if (o.w * o.h <= box.w * box.h) continue;
|
|
527
|
+
if (!best || o.w * o.h < best.w * best.h) best = o;
|
|
528
|
+
}
|
|
529
|
+
return best;
|
|
530
|
+
};
|
|
531
|
+
{
|
|
532
|
+
const dAll = measured.map((m) => ({ ...m.d, label: m.name }));
|
|
533
|
+
const lAll = measured.map((m) => ({ ...m.l, label: m.name }));
|
|
534
|
+
measured.forEach((m, i) => {
|
|
535
|
+
m.dParent = containerOf(dAll[i], dAll);
|
|
536
|
+
m.lParent = containerOf(lAll[i], lAll);
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const designWpx = figmaFrame && figmaFrame.w > 0 ? figmaFrame.w : null;
|
|
541
|
+
const liveWpx = regionSize ? regionSize.w : (liveScreen && liveScreen.w > 0 ? liveScreen.w : null);
|
|
542
|
+
for (const m of measured) {
|
|
543
|
+
m.px = null;
|
|
544
|
+
if (!responsive || !designWpx || !liveWpx) continue;
|
|
545
|
+
if (!(m.d.w >= designWpx * 0.8 && m.l.w >= liveWpx * 0.8)) continue;
|
|
546
|
+
const vf = visualRowInsets(figma, m.d.y + m.d.h / 2, designWpx, figmaFrame.h);
|
|
547
|
+
const vl = visualRowInsets(live, m.l.y + m.l.h / 2, liveWpx, liveScreen.h);
|
|
548
|
+
if (vf && vl) m.px = { design: vf, live: vl };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const flaggedBoxes = new Set();
|
|
552
|
+
const flag = (m) => flaggedBoxes.add(m);
|
|
553
|
+
|
|
554
|
+
// Measurements are in points now, so `deltaPt` is the accurate field. Keep a
|
|
555
|
+
// `deltaPx` alias so callers written against the older shape keep working.
|
|
556
|
+
const pushMeasured = (f) => {
|
|
557
|
+
if (f.deltaPt && !f.deltaPx) f.deltaPx = f.deltaPt;
|
|
558
|
+
findings.push(f);
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
for (const m of measured) {
|
|
562
|
+
const { d, l, name, box } = m;
|
|
563
|
+
const region = box;
|
|
564
|
+
|
|
565
|
+
// Vertical placement is directly comparable in points - both are measured
|
|
566
|
+
// from the top of the same screen.
|
|
567
|
+
const dTop = Math.round(l.y - d.y);
|
|
568
|
+
if (Math.abs(dTop) > tolPt) {
|
|
569
|
+
// Absolute Y is shifted by everything above it, so a different mock
|
|
570
|
+
// fixture moves it without anything being wrong. The real vertical
|
|
571
|
+
// signals are the sibling GAP and the relational checks below; keep this
|
|
572
|
+
// as context only.
|
|
573
|
+
pushMeasured({
|
|
574
|
+
type: "spacing", category: "spacing", element: name, region, advisory: true,
|
|
575
|
+
detail: `top offset ${l.y.toFixed(0)}pt vs design ${d.y.toFixed(0)}pt (${dTop >= 0 ? "+" : ""}${dTop}pt) - advisory: absolute Y moves with the content above it; see the gap/alignment findings`,
|
|
576
|
+
expected: `top ${d.y.toFixed(0)}pt`, actual: `top ${l.y.toFixed(0)}pt`,
|
|
577
|
+
deltaPt: { y: dTop },
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Edge insets - the responsive-safe replacement for a raw width delta.
|
|
582
|
+
if (canResolveInsets) {
|
|
583
|
+
let dInsetL, dInsetR, insetRDesign, insetRLive, insetLDesign = d.x, insetLLive = l.x, src = "geometry";
|
|
584
|
+
|
|
585
|
+
// For a row that spans (nearly) the whole frame, trust the pixels over the
|
|
586
|
+
// two geometry sources: the design side is usually an instance wrapper and
|
|
587
|
+
// the live side the visible card, and that mismatch alone invents a ~16pt
|
|
588
|
+
// inset on a conformant screen.
|
|
589
|
+
const vf = m.px && m.px.design, vl = m.px && m.px.live;
|
|
590
|
+
// Nested in a container on BOTH sides -> measure against it, and the two
|
|
591
|
+
// sides become directly comparable whatever the device width.
|
|
592
|
+
if (m.dParent && m.lParent) {
|
|
593
|
+
insetLDesign = m.d.x - m.dParent.x;
|
|
594
|
+
insetLLive = m.l.x - m.lParent.x;
|
|
595
|
+
insetRDesign = (m.dParent.x + m.dParent.w) - (m.d.x + m.d.w);
|
|
596
|
+
insetRLive = (m.lParent.x + m.lParent.w) - (m.l.x + m.l.w);
|
|
597
|
+
src = `parent ${m.dParent.label || "container"}`;
|
|
598
|
+
} else if (vf && vl) {
|
|
599
|
+
insetLDesign = vf.left; insetLLive = vl.left;
|
|
600
|
+
insetRDesign = vf.right; insetRLive = vl.right;
|
|
601
|
+
src = "pixels";
|
|
602
|
+
} else {
|
|
603
|
+
insetRDesign = designW - (d.x + d.w);
|
|
604
|
+
insetRLive = liveW - (l.x + l.w);
|
|
605
|
+
}
|
|
606
|
+
dInsetL = Math.round(insetLLive - insetLDesign);
|
|
607
|
+
dInsetR = Math.round(insetRLive - insetRDesign);
|
|
608
|
+
if (Math.abs(dInsetL) > tolPt) {
|
|
609
|
+
pushMeasured({
|
|
610
|
+
type: "inset", category: "padding", element: name, region, measuredFrom: src,
|
|
611
|
+
advisory: !(src === "pixels" || src.startsWith("parent")),
|
|
612
|
+
detail: `left inset ${insetLLive.toFixed(0)}pt vs design ${insetLDesign.toFixed(0)}pt (${dInsetL >= 0 ? "+" : ""}${dInsetL}pt, ${src})` + ((src === "pixels" || src.startsWith("parent")) ? "" : " - advisory: no pixel confirmation, one side may be a wrapper box and the other a glyph run"),
|
|
613
|
+
expected: `left ${insetLDesign.toFixed(0)}pt`, actual: `left ${insetLLive.toFixed(0)}pt`,
|
|
614
|
+
deltaPt: { left: dInsetL },
|
|
615
|
+
});
|
|
616
|
+
flag(m);
|
|
617
|
+
}
|
|
618
|
+
if (Math.abs(dInsetR) > tolPt) {
|
|
619
|
+
pushMeasured({
|
|
620
|
+
type: "inset", category: "padding", element: name, region, measuredFrom: src,
|
|
621
|
+
advisory: !(src === "pixels" || src.startsWith("parent")),
|
|
622
|
+
detail: `right inset ${insetRLive.toFixed(0)}pt vs design ${insetRDesign.toFixed(0)}pt (${dInsetR >= 0 ? "+" : ""}${dInsetR}pt, ${src})` + ((src === "pixels" || src.startsWith("parent")) ? "" : " - advisory: no pixel confirmation, one side may be a wrapper box and the other a glyph run"),
|
|
623
|
+
expected: `right ${insetRDesign.toFixed(0)}pt`, actual: `right ${insetRLive.toFixed(0)}pt`,
|
|
624
|
+
deltaPt: { right: dInsetR },
|
|
625
|
+
});
|
|
626
|
+
flag(m);
|
|
627
|
+
}
|
|
628
|
+
} else {
|
|
629
|
+
const dW = Math.round(l.w - d.w);
|
|
630
|
+
if (Math.abs(dW) > tolPt) {
|
|
631
|
+
pushMeasured({
|
|
632
|
+
type: "size", category: "size", element: name, region,
|
|
633
|
+
detail: `width ${l.w.toFixed(0)}pt vs design ${d.w.toFixed(0)}pt (${dW >= 0 ? "+" : ""}${dW}pt)`,
|
|
634
|
+
expected: `${d.w.toFixed(0)}pt`, actual: `${l.w.toFixed(0)}pt`, deltaPt: { w: dW },
|
|
635
|
+
});
|
|
636
|
+
flag(m);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Height is reported, but a container that hugs its content changes height
|
|
641
|
+
// whenever the fixture returns a different number of rows - which is not a
|
|
642
|
+
// design defect. Mark it advisory so it never outranks a real inset/gap.
|
|
643
|
+
const dH = Math.round(l.h - d.h);
|
|
644
|
+
if (Math.abs(dH) > tolPt) {
|
|
645
|
+
pushMeasured({
|
|
646
|
+
type: "height", category: "size", element: name, region, advisory: true,
|
|
647
|
+
detail: `height ${l.h.toFixed(0)}pt vs design ${d.h.toFixed(0)}pt (${dH >= 0 ? "+" : ""}${dH}pt) - advisory: content-driven height differs when the fixture differs`,
|
|
648
|
+
expected: `${d.h.toFixed(0)}pt`, actual: `${l.h.toFixed(0)}pt`, deltaPt: { h: dH },
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// ── Typography ───────────────────────────────────────────────────────────
|
|
653
|
+
// Font size is not exposed by the accessibility tree, so infer it from the
|
|
654
|
+
// rendered line box only when the design says how tall one line should be.
|
|
655
|
+
if (fe_fontSize(m)) {
|
|
656
|
+
const want = fe_fontSize(m);
|
|
657
|
+
const designLine = d.h;
|
|
658
|
+
if (designLine > 0) {
|
|
659
|
+
const impliedPt = (l.h / designLine) * want;
|
|
660
|
+
const dPt = impliedPt - want;
|
|
661
|
+
if (Math.abs(dPt) > Math.max(1, want * 0.12)) {
|
|
662
|
+
pushMeasured({
|
|
663
|
+
type: "font-size", category: "font size", element: name, region,
|
|
664
|
+
detail: `line box implies ~${impliedPt.toFixed(1)}pt vs design ${want}pt${m.fe.fontFamily ? ` (${m.fe.fontFamily})` : ""}`,
|
|
665
|
+
expected: `${want}pt${m.fe.fontFamily ? ` ${m.fe.fontFamily}` : ""}`,
|
|
666
|
+
actual: `~${impliedPt.toFixed(1)}pt`,
|
|
667
|
+
});
|
|
668
|
+
flag(m);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
// Font family cannot be measured from the device; surface it as a spec value
|
|
673
|
+
// to verify in code rather than inventing a delta.
|
|
674
|
+
if (m.fe.fontFamily && verifyFontFamily) {
|
|
675
|
+
pushMeasured({
|
|
676
|
+
type: "font-family", category: "typography", element: name, region, advisory: true,
|
|
677
|
+
detail: `design font family ${m.fe.fontFamily} - not readable from the device, verify in code`,
|
|
678
|
+
expected: m.fe.fontFamily, actual: "not measurable",
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
// Colour: text uses the ink sampler, filled boxes the box average.
|
|
682
|
+
if (m.fe.color) {
|
|
683
|
+
const want = hexToRgb(m.fe.color);
|
|
684
|
+
const isText = !!(m.fe.text || m.fe.fontSize);
|
|
685
|
+
const got = isText
|
|
686
|
+
? sampleInkColor(liveResized, box.x, box.y, box.w, box.h)
|
|
687
|
+
: sampleColor(liveResized, box.x, box.y, box.w, box.h);
|
|
688
|
+
if (want && got) {
|
|
689
|
+
const dE = deltaE(want, got);
|
|
690
|
+
if (dE > colorTolerance) {
|
|
691
|
+
pushMeasured({
|
|
692
|
+
type: "color", category: isText ? "font color" : "color", element: name, region,
|
|
693
|
+
detail: `${isText ? "text" : "fill"} colour ΔE ${dE.toFixed(1)} - expected ${rgbToHex(want)}, got ${rgbToHex(got)}`,
|
|
694
|
+
expected: rgbToHex(want), actual: rgbToHex(got), deltaE: parseFloat(dE.toFixed(1)),
|
|
695
|
+
});
|
|
696
|
+
flag(m);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// ── Edge context: the on-device-inspector measurement, both sides ──────────
|
|
703
|
+
// Run the identical local-distance algorithm over the design boxes and the
|
|
704
|
+
// live boxes, then compare direction by direction. A row that moved because
|
|
705
|
+
// the fixture above it grew keeps its OWN four distances, so it stays silent -
|
|
706
|
+
// while a padding or gap that genuinely changed shows up on the exact edge
|
|
707
|
+
// that changed, naming what it is measured against.
|
|
708
|
+
if (edges && canResolveInsets) {
|
|
709
|
+
const dAll = measured.map((m) => ({ ...m.d, label: m.name }));
|
|
710
|
+
const lAll = measured.map((m) => ({ ...m.l, label: m.name }));
|
|
711
|
+
for (let i = 0; i < measured.length; i++) {
|
|
712
|
+
const m = measured[i];
|
|
713
|
+
const dc = edgeContext(dAll[i], dAll, designW, figmaFrame ? figmaFrame.h : null);
|
|
714
|
+
const lc = edgeContext(lAll[i], lAll, liveW, liveScreen ? liveScreen.h : null);
|
|
715
|
+
for (const side of ["left", "right", "top", "bottom"]) {
|
|
716
|
+
const dv = dc[side], lv = lc[side];
|
|
717
|
+
if (!Number.isFinite(dv) || !Number.isFinite(lv)) continue;
|
|
718
|
+
const delta = Math.round(lv - dv);
|
|
719
|
+
if (Math.abs(delta) <= tolPt) continue;
|
|
720
|
+
// Report only what no other check already covers, or the same fact lands
|
|
721
|
+
// in the report two and three times over:
|
|
722
|
+
// - left/right to the FRAME -> `inset` does it, and does it from the
|
|
723
|
+
// pixels, so it is not fooled by a wrapper-vs-visible mismatch
|
|
724
|
+
// - top/bottom to a SIBLING -> `gap` is exactly this measurement
|
|
725
|
+
// - top/bottom to the FRAME -> not local (handled as advisory below)
|
|
726
|
+
// What is left, and genuinely new, is the horizontal distance between
|
|
727
|
+
// two siblings: an icon-to-label or label-to-divider gap.
|
|
728
|
+
const toFrame = dc[`${side}Ref`] === "frame" || lc[`${side}Ref`] === "frame";
|
|
729
|
+
const horizontal = side === "left" || side === "right";
|
|
730
|
+
if (!horizontal || toFrame) continue;
|
|
731
|
+
// Only compare like against like: a distance measured to a sibling in the
|
|
732
|
+
// design but to the frame in live is a different quantity.
|
|
733
|
+
const dRef = dc[`${side}Ref`], lRef = lc[`${side}Ref`];
|
|
734
|
+
// A trailing edge sits closer purely because the element itself grew, so
|
|
735
|
+
// an element whose own size already differs would otherwise have that one
|
|
736
|
+
// fact reported twice - once as size, again as padding.
|
|
737
|
+
// A right edge sits closer purely because the element itself grew, which
|
|
738
|
+
// is already reported as size.
|
|
739
|
+
const ownSizeDiffers = side === "right" &&
|
|
740
|
+
Math.abs(m.l.w - m.d.w) > tolPt;
|
|
741
|
+
const sameRef = dRef === lRef && !ownSizeDiffers;
|
|
742
|
+
pushMeasured({
|
|
743
|
+
type: "edge", category: "padding", element: m.name, region: m.box,
|
|
744
|
+
advisory: !sameRef,
|
|
745
|
+
detail: `${side} distance ${lv.toFixed(0)}pt vs design ${dv.toFixed(0)}pt (${delta >= 0 ? "+" : ""}${delta}pt), measured to ${lRef}` +
|
|
746
|
+
(sameRef ? "" : ownSizeDiffers
|
|
747
|
+
? " - advisory: explained by this element's own size difference, already reported as size"
|
|
748
|
+
: ` in live but to ${dRef} in design - not comparable, advisory only`),
|
|
749
|
+
expected: `${side} ${dv.toFixed(0)}pt to ${dRef}`,
|
|
750
|
+
actual: `${side} ${lv.toFixed(0)}pt to ${lRef}`,
|
|
751
|
+
deltaPt: { [side]: delta },
|
|
752
|
+
});
|
|
753
|
+
if (sameRef) flag(m);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// ── Relational checks: is it built 1:1? ────────────────────────────────────
|
|
759
|
+
// Absolute positions answer the wrong question. Two screens showing the same
|
|
760
|
+
// component with different mock content put it at different Y, so an absolute
|
|
761
|
+
// delta flags a conformant screen and hides a real one. What actually says
|
|
762
|
+
// "1:1" is intrinsic and content-independent:
|
|
763
|
+
// - is it the same distance from the edge?
|
|
764
|
+
// - is it still centred?
|
|
765
|
+
// - are the things that shared a centre line still sharing it?
|
|
766
|
+
// - is the icon still the size it was?
|
|
767
|
+
// Each is computed the same way on BOTH sides and only the DIFFERENCE is a
|
|
768
|
+
// finding - so a 247pt design text container vs a 78pt live glyph box (the
|
|
769
|
+
// same text, measured differently) stays silent, while a 28pt icon rendered at
|
|
770
|
+
// 24pt does not.
|
|
771
|
+
if (relations && canResolveInsets) {
|
|
772
|
+
const midX = (b) => b.x + b.w / 2;
|
|
773
|
+
const midY = (b) => b.y + b.h / 2;
|
|
774
|
+
|
|
775
|
+
for (const m of measured) {
|
|
776
|
+
const { d, l, name, box } = m;
|
|
777
|
+
|
|
778
|
+
// Centred in the frame: compare the OFFSET FROM CENTRE, not the position.
|
|
779
|
+
// Prefer the pixel edges - a wrapper box is trivially centred and a live AX
|
|
780
|
+
// union rarely is, which invents an off-centre finding on a correct screen.
|
|
781
|
+
const dEdge = m.px ? m.px.design : { left: d.x, right: designW - (d.x + d.w) };
|
|
782
|
+
const lEdge = m.px ? m.px.live : { left: l.x, right: liveW - (l.x + l.w) };
|
|
783
|
+
const offD = (dEdge.left - dEdge.right) / 2;
|
|
784
|
+
const offL = (lEdge.left - lEdge.right) / 2;
|
|
785
|
+
const wasCentred = Math.abs(offD) <= tolPt;
|
|
786
|
+
if (wasCentred && Math.abs(offL) > tolPt) {
|
|
787
|
+
pushMeasured({
|
|
788
|
+
type: "alignment", category: "padding", element: name, region: box,
|
|
789
|
+
detail: `design centres this in the frame; live is ${Math.abs(offL).toFixed(0)}pt off centre`,
|
|
790
|
+
expected: "centred", actual: `${offL >= 0 ? "+" : "-"}${Math.abs(offL).toFixed(0)}pt off centre`,
|
|
791
|
+
deltaPt: { centre: Math.round(offL - offD) },
|
|
792
|
+
});
|
|
793
|
+
flag(m);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Symmetric side margins - a designer's "equal padding" intent.
|
|
797
|
+
const symD = Math.abs(dEdge.left - dEdge.right);
|
|
798
|
+
const symL = Math.abs(lEdge.left - lEdge.right);
|
|
799
|
+
if (symD <= tolPt && symL > tolPt) {
|
|
800
|
+
pushMeasured({
|
|
801
|
+
type: "alignment", category: "padding", element: name, region: box,
|
|
802
|
+
detail: `design keeps left/right margins equal; live differs by ${symL.toFixed(0)}pt`,
|
|
803
|
+
expected: "left margin = right margin", actual: `${symL.toFixed(0)}pt apart`,
|
|
804
|
+
deltaPt: { symmetry: Math.round(symL - symD) },
|
|
805
|
+
});
|
|
806
|
+
flag(m);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// Icon / control sizing is fixed by the design, never by content - so
|
|
810
|
+
// unlike a container's height this is a real deviation, not an advisory.
|
|
811
|
+
const isSmall = d.w <= 64 && d.h <= 64;
|
|
812
|
+
if (isSmall) {
|
|
813
|
+
const dW = Math.round(l.w - d.w), dH = Math.round(l.h - d.h);
|
|
814
|
+
if (Math.abs(dW) > tolPt || Math.abs(dH) > tolPt) {
|
|
815
|
+
pushMeasured({
|
|
816
|
+
type: "icon-size", category: "size", element: name, region: box,
|
|
817
|
+
detail: `${l.w.toFixed(0)}x${l.h.toFixed(0)}pt vs design ${d.w.toFixed(0)}x${d.h.toFixed(0)}pt` +
|
|
818
|
+
(l.w < 44 || l.h < 44 ? " - also under the 44pt minimum tap target" : ""),
|
|
819
|
+
expected: `${d.w.toFixed(0)}x${d.h.toFixed(0)}pt`, actual: `${l.w.toFixed(0)}x${l.h.toFixed(0)}pt`,
|
|
820
|
+
deltaPt: { w: dW, h: dH },
|
|
821
|
+
});
|
|
822
|
+
flag(m);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// Shared centre lines: elements the design puts on one axis must stay on one
|
|
828
|
+
// axis. This is how "is the X aligned with the title" gets checked without
|
|
829
|
+
// caring where either of them sits on screen.
|
|
830
|
+
for (let i = 0; i < measured.length; i++) {
|
|
831
|
+
for (let j = i + 1; j < measured.length; j++) {
|
|
832
|
+
const a = measured[i], b = measured[j];
|
|
833
|
+
const sharedY = Math.abs(midY(a.d) - midY(b.d)) <= tolPt;
|
|
834
|
+
if (sharedY) {
|
|
835
|
+
const liveOff = Math.abs(midY(a.l) - midY(b.l));
|
|
836
|
+
if (liveOff > tolPt) {
|
|
837
|
+
pushMeasured({
|
|
838
|
+
type: "alignment", category: "spacing",
|
|
839
|
+
element: `${a.name} ↔ ${b.name}`, region: b.box,
|
|
840
|
+
detail: `design puts these on one horizontal centre line; live is ${liveOff.toFixed(0)}pt apart`,
|
|
841
|
+
expected: "same centre-y", actual: `${liveOff.toFixed(0)}pt apart`,
|
|
842
|
+
deltaPt: { centreY: Math.round(liveOff) },
|
|
843
|
+
});
|
|
844
|
+
flag(b);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// ── Gaps between consecutive elements ──────────────────────────────────────
|
|
852
|
+
// "text ↔ divider" spacing is what a designer actually specifies, and it is
|
|
853
|
+
// invariant to frame width. Walk the pairs in design order and compare the
|
|
854
|
+
// vertical gap each side leaves between neighbours.
|
|
855
|
+
const ordered = [...measured].sort((a, b) => a.d.y - b.d.y);
|
|
856
|
+
for (let i = 0; i + 1 < ordered.length; i++) {
|
|
857
|
+
const a = ordered[i], b = ordered[i + 1];
|
|
858
|
+
const gapDesign = b.d.y - (a.d.y + a.d.h);
|
|
859
|
+
const gapLive = b.l.y - (a.l.y + a.l.h);
|
|
860
|
+
if (gapDesign < -1 || gapLive < -1) continue; // overlapping / nested - not a gap
|
|
861
|
+
const dGap = Math.round(gapLive - gapDesign);
|
|
862
|
+
if (Math.abs(dGap) > tolPt) {
|
|
863
|
+
pushMeasured({
|
|
864
|
+
type: "gap", category: "spacing",
|
|
865
|
+
element: `${a.name} → ${b.name}`,
|
|
866
|
+
region: { x: b.box.x, y: Math.round((a.box.y + a.box.h + b.box.y) / 2), w: b.box.w, h: Math.max(4, b.box.y - (a.box.y + a.box.h)) },
|
|
867
|
+
detail: `gap ${gapLive.toFixed(0)}pt vs design ${gapDesign.toFixed(0)}pt (${dGap >= 0 ? "+" : ""}${dGap}pt)`,
|
|
868
|
+
expected: `${gapDesign.toFixed(0)}pt`, actual: `${gapLive.toFixed(0)}pt`,
|
|
869
|
+
deltaPt: { gap: dGap },
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
for (const m of flaggedBoxes) drawRect(overlay, m.box.x, m.box.y, m.box.w, m.box.h, RED, 3);
|
|
875
|
+
|
|
876
|
+
// Write images
|
|
877
|
+
const stamp = `${norm(label).replace(/[^a-z0-9]+/g, "-") || "screen"}`;
|
|
878
|
+
const paths = {};
|
|
879
|
+
const write = (name, png) => { const p = join(outDir, `${stamp}-${name}.png`); writeFileSync(p, PNG.sync.write(png)); return p; };
|
|
880
|
+
paths.figma = write("figma", figma);
|
|
881
|
+
paths.live = write("live", live); // NATIVE aspect (cropped) - undistorted display
|
|
882
|
+
paths.liveNormalized = write("live-norm", liveResized);
|
|
883
|
+
paths.diff = write("diff", diff);
|
|
884
|
+
paths.overlay = write("overlay", overlay);
|
|
885
|
+
|
|
886
|
+
// Side-by-side: figma | live | overlay
|
|
887
|
+
const gap = 24;
|
|
888
|
+
const sbs = new PNG({ width: W * 3 + gap * 2, height: H });
|
|
889
|
+
sbs.data.fill(255);
|
|
890
|
+
const blit = (src, offX) => {
|
|
891
|
+
for (let y = 0; y < H; y++) {
|
|
892
|
+
const s = (y * W) * 4;
|
|
893
|
+
const d = (y * sbs.width + offX) * 4;
|
|
894
|
+
src.data.copy(sbs.data, d, s, s + W * 4);
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
blit(figma, 0); blit(liveResized, W + gap); blit(overlay, (W + gap) * 2);
|
|
898
|
+
paths.sideBySide = write("side-by-side", sbs);
|
|
899
|
+
|
|
900
|
+
// Copy is compared against the design's UX writing, not the frame render.
|
|
901
|
+
if (designCopy && designCopy.length) {
|
|
902
|
+
const liveTexts = liveGeometry.map((e) => e.text || e.label).filter(Boolean);
|
|
903
|
+
for (const f of compareCopy(designCopy, liveTexts)) findings.push(f);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// Content-cardinality demotion (gaps doc section 1). A design frame carries
|
|
907
|
+
// whatever the mock fixture produced, so 5 design rows against 3 live rows makes
|
|
908
|
+
// every container height and downstream position differ for a reason that is not
|
|
909
|
+
// a defect. Detected from the element lists and applied LAST, after every finding
|
|
910
|
+
// exists, so the same rule sees all of them.
|
|
911
|
+
//
|
|
912
|
+
// Nothing is deleted: affected geometry findings become advisory with a shared
|
|
913
|
+
// rootCause, which is already excluded from the deviation count and rendered
|
|
914
|
+
// under "DO NOT CHANGE". Copy / colour / typography / tap-target findings and
|
|
915
|
+
// anything above the first differing group are never touched - see
|
|
916
|
+
// content-cardinality.js for the full rule set and why it is written this way.
|
|
917
|
+
//
|
|
918
|
+
// Opt out with contentCardinality:false to see the raw findings.
|
|
919
|
+
let cardinality = { mismatch: false, groups: [], boundaryY: null };
|
|
920
|
+
let cardinalityDemoted = 0;
|
|
921
|
+
if (contentCardinality) {
|
|
922
|
+
cardinality = detectCardinalityMismatch(figmaSpec, liveGeometry);
|
|
923
|
+
if (cardinality.mismatch) {
|
|
924
|
+
const res = demoteCardinalityNoise(findings, cardinality);
|
|
925
|
+
findings.length = 0;
|
|
926
|
+
findings.push(...res.findings);
|
|
927
|
+
cardinalityDemoted = res.demoted;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// Advisories (content-driven height, unmeasurable font family) must not decide
|
|
932
|
+
// pass/fail - otherwise a fixture with one extra row fails a conformant screen.
|
|
933
|
+
const deviations = findings.filter((f) => !f.advisory);
|
|
934
|
+
const passed = perceptualPct <= maxDiffPct && deviations.length === 0;
|
|
935
|
+
return {
|
|
936
|
+
label, passed,
|
|
937
|
+
// Reported so a demotion is never silent: a run can say what it set aside and
|
|
938
|
+
// why, which is the difference between suppressing noise and hiding findings.
|
|
939
|
+
contentCardinality: {
|
|
940
|
+
mismatch: cardinality.mismatch,
|
|
941
|
+
groups: cardinality.groups,
|
|
942
|
+
demoted: cardinalityDemoted,
|
|
943
|
+
},
|
|
944
|
+
perceptualPct, diffPixels, maxDiffPct,
|
|
945
|
+
findingCount: findings.length,
|
|
946
|
+
deviationCount: deviations.length,
|
|
947
|
+
advisoryCount: findings.length - deviations.length,
|
|
948
|
+
findings,
|
|
949
|
+
images: paths,
|
|
950
|
+
compareSize: `${W}x${H}`,
|
|
951
|
+
liveSize: `${live.width}x${live.height}`,
|
|
952
|
+
measurement: {
|
|
953
|
+
mode: canResolveInsets ? "responsive (points)" : "absolute (points)",
|
|
954
|
+
designFrameW: designW, liveFrameW: liveW,
|
|
955
|
+
tolerancePt: tolPt,
|
|
956
|
+
note: canResolveInsets
|
|
957
|
+
? "Edge insets and inter-element gaps measured in points; raw width suppressed because the design frame and the device differ in width. Height is advisory."
|
|
958
|
+
: "figmaFrame/liveScreen width missing - fell back to absolute point deltas.",
|
|
959
|
+
},
|
|
960
|
+
};
|
|
961
|
+
}
|