figma-to-html-pixel-perfect 1.0.0

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.
@@ -0,0 +1,1320 @@
1
+ #!/usr/bin/env python3
2
+ """Generate a self-verifying fidelity report the user can open and judge themselves.
3
+
4
+ For every section it puts the Figma reference next to the live page, adds an opacity
5
+ slider and a difference-blend overlay, and runs the numeric checks in the browser
6
+ (section heights, container offset, horizontal overflow, resolved fonts, console errors).
7
+
8
+ Nothing here asserts "it matches" — it shows the evidence and prints the numbers.
9
+
10
+ Inputs (all produced earlier in the workflow):
11
+ figma/nodes/<section>.json node trees (for y offsets + expected heights)
12
+ figma/renders/ref_<section>.png reference slices (what the design looks like)
13
+ <page> the built page (served over http)
14
+
15
+ Usage:
16
+ python3 figma_report.py --page index.html \
17
+ [--nodes figma/nodes] [--renders figma/renders] \
18
+ [--selectors selectors.json] [--out fidelity-report.html]
19
+
20
+ `selectors.json` is optional: {"hero": ".hero", "footer": ".footer", ...}. Without it the
21
+ report measures `header, main > section, footer` in DOM order.
22
+ """
23
+ import argparse, glob, json, os, pathlib, re, sys
24
+
25
+
26
+ def load_sections(nodes_dir, renders_dir):
27
+ refs = {}
28
+ for p in glob.glob(os.path.join(renders_dir, "ref_*.png")):
29
+ refs[re.sub(r"^ref_", "", pathlib.Path(p).stem)] = p
30
+ if not refs:
31
+ raise SystemExit(f"No ref_*.png in {renders_dir}. Slice the page export first.")
32
+
33
+ # A section with node JSON but no reference slice is a section nobody is checking.
34
+ # This is exactly how a whole section goes missing without anyone noticing.
35
+ known = {pathlib.Path(f).stem for f in glob.glob(os.path.join(nodes_dir, "*.json"))}
36
+ unverified = sorted(known - set(refs))
37
+ if unverified:
38
+ print("WARNING: no reference slice for: " + ", ".join(unverified))
39
+ print(" these sections are NOT verified by this report.")
40
+
41
+ boxes = {}
42
+ for name in refs:
43
+ f = os.path.join(nodes_dir, f"{name}.json")
44
+ if not os.path.exists(f):
45
+ continue
46
+ d = json.load(open(f))
47
+ d = d["document"] if "document" in d else d
48
+ bb = d.get("absoluteBoundingBox") or {}
49
+ boxes[name] = (bb.get("x", 0), bb.get("y", 0), bb.get("width", 0), bb.get("height", 0))
50
+ if not boxes:
51
+ raise SystemExit(f"No matching node JSON in {nodes_dir}")
52
+
53
+ origin_y = min(b[1] for b in boxes.values())
54
+ origin_x = min(b[0] for b in boxes.values())
55
+
56
+ def content_extents(node, sx, sw):
57
+ """Left/right edge of the real content: ignore full-bleed backgrounds and
58
+ anything hidden (visible:false, or effective ancestor opacity 0)."""
59
+ lo, hi = [], []
60
+
61
+ def walk(n, op=1.0):
62
+ if n.get("visible") is False:
63
+ return
64
+ op *= n.get("opacity", 1)
65
+ if op == 0:
66
+ return
67
+ bb = n.get("absoluteBoundingBox") or {}
68
+ w = bb.get("width", 0)
69
+ if bb and 0 < w < sw * 0.95:
70
+ # clamp into the frame: carousels legitimately overflow and are clipped
71
+ lo.append(max(0, bb["x"] - sx))
72
+ hi.append(min(sw, bb["x"] + w - sx))
73
+ for c in n.get("children", []) or []:
74
+ walk(c, op)
75
+
76
+ walk(node)
77
+ return (round(min(lo)), round(max(hi))) if lo else (None, None)
78
+
79
+ def hexc(node):
80
+ for f in node.get("fills") or []:
81
+ if f.get("visible") is False:
82
+ continue
83
+ if f.get("type") == "SOLID":
84
+ c = f["color"]
85
+ return "#%02x%02x%02x" % tuple(round(c[k] * 255) for k in "rgb")
86
+ return None
87
+
88
+ VECTORISH = {"VECTOR", "BOOLEAN_OPERATION", "ELLIPSE", "RECTANGLE", "LINE",
89
+ "REGULAR_POLYGON", "STAR"}
90
+ # only these carry an icon's drawn outline; a subtree of plain ellipses is a shape
91
+ # (carousel dots, a ring) that CSS draws, and demanding an <img> there is a false alarm
92
+ GLYPHISH = {"VECTOR", "BOOLEAN_OPERATION", "REGULAR_POLYGON", "STAR"}
93
+
94
+ def opaque(n):
95
+ if n.get("visible") is False or n.get("opacity", 1) < 0.99:
96
+ return False
97
+ for f in n.get("fills") or []:
98
+ if f.get("visible") is False or f.get("opacity", 1) < 0.99:
99
+ continue
100
+ if f.get("type") == "IMAGE":
101
+ return True
102
+ if f.get("type") == "SOLID" and f.get("color", {}).get("a", 1) >= 0.99:
103
+ return True
104
+ return False
105
+
106
+ def covers(n, bb, pad=0.5):
107
+ if n.get("visible") is False or n.get("opacity", 1) < 0.99:
108
+ return False
109
+ b = n.get("absoluteBoundingBox") or {}
110
+ if b and opaque(n) and (b["x"] - pad <= bb["x"] and b["y"] - pad <= bb["y"]
111
+ and b["x"] + b["width"] + pad >= bb["x"] + bb["width"]
112
+ and b["y"] + b["height"] + pad >= bb["y"] + bb["height"]):
113
+ return True
114
+ return any(covers(c, bb, pad) for c in n.get("children") or [])
115
+
116
+ def occluded(bb, stack):
117
+ """A vector that a later sibling paints over never renders. Demanding an <img>
118
+ there would send you chasing an icon nobody can see."""
119
+ for parent, i in stack:
120
+ for later in (parent.get("children") or [])[i + 1:]:
121
+ if covers(later, bb):
122
+ return True
123
+ return False
124
+
125
+ def graphic_nodes(node, sx, sy, section=""):
126
+ """Icon boxes (small, pure-vector subtrees) and image boxes (IMAGE fills).
127
+
128
+ Each icon carries the filename figma_icons.py gives it, so the report can check
129
+ *which* icon landed on the node — not merely that some icon did."""
130
+ icons, images = [], []
131
+
132
+ def only_vectors(n):
133
+ if n.get("type") == "TEXT":
134
+ return False
135
+ kids = n.get("children") or []
136
+ if not kids:
137
+ return n.get("type") in VECTORISH
138
+ return all(only_vectors(k) for k in kids)
139
+
140
+ def has_vector(n):
141
+ if n.get("type") in GLYPHISH:
142
+ return True
143
+ return any(has_vector(k) for k in n.get("children") or [])
144
+
145
+ def splittable(n):
146
+ """Several separate icons in one frame (pager arrows, a star row) are audited
147
+ one by one, exactly as figma_icons.py exports them."""
148
+ kids = [c for c in (n.get("children") or []) if c.get("visible") is not False]
149
+ if len(kids) < 2 or any(k.get("type") in VECTORISH for k in kids):
150
+ return False
151
+ bbs = [k.get("absoluteBoundingBox") or {} for k in kids]
152
+ if not all(b and 10 <= b["width"] <= 220 and 10 <= b["height"] <= 220 for b in bbs):
153
+ return False
154
+ for i, p_ in enumerate(bbs):
155
+ for q in bbs[i + 1:]:
156
+ if (p_["x"] < q["x"] + q["width"] and q["x"] < p_["x"] + p_["width"]
157
+ and p_["y"] < q["y"] + q["height"] and q["y"] < p_["y"] + p_["height"]):
158
+ return False
159
+ return True
160
+
161
+ def has_image(n):
162
+ """A shape with an IMAGE fill is a photo; it is audited as an image, not an icon."""
163
+ if any(f.get("type") == "IMAGE" and f.get("visible") is not False
164
+ for f in n.get("fills") or []):
165
+ return True
166
+ return any(has_image(k) for k in n.get("children") or [])
167
+
168
+ def walk(n, op=1.0, stack=()):
169
+ if n.get("visible") is False:
170
+ return
171
+ op *= n.get("opacity", 1)
172
+ if op == 0:
173
+ return
174
+ bb = n.get("absoluteBoundingBox") or {}
175
+ for f in n.get("fills") or []:
176
+ if f.get("visible") is not False and f.get("type") == "IMAGE" and bb.get("width", 0) >= 40:
177
+ images.append({"x": round(bb["x"] - sx), "y": round(bb["y"] - sy),
178
+ "w": round(bb["width"]), "h": round(bb["height"]),
179
+ "ref": f.get("imageRef")})
180
+ break
181
+ # The OUTERMOST pure-vector subtree is one icon. Recursing further would count
182
+ # every glyph outline of a logo as its own "missing icon".
183
+ if bb and 10 <= bb.get("width", 0) <= 220 and 10 <= bb.get("height", 0) <= 220 \
184
+ and only_vectors(n) and has_vector(n) and not has_image(n) \
185
+ and not splittable(n):
186
+ if not occluded(bb, stack):
187
+ ib = {"x": round(bb["x"] - sx), "y": round(bb["y"] - sy),
188
+ "w": round(bb["width"]), "h": round(bb["height"])}
189
+ # which file figma_icons.py exported for this exact node
190
+ key = (section, ib["x"] + round(sx), ib["y"] + round(sy))
191
+ ib["file"] = MANIFEST.get(f"{section}|{round(bb['x'])}|{round(bb['y'])}")
192
+ icons.append(ib)
193
+ return
194
+ for i, c in enumerate(n.get("children", []) or []):
195
+ walk(c, op, stack + ((n, i),))
196
+
197
+ walk(node)
198
+ return icons, images
199
+
200
+ def hover_nodes(node, sx, sy):
201
+ """Nodes the design says react to hover, with the duration/easing it specifies.
202
+ The durations are already in the node JSON — verifying them needs no extra fetch."""
203
+ out = []
204
+
205
+ def walk(n, op=1.0):
206
+ if n.get("visible") is False:
207
+ return
208
+ op *= n.get("opacity", 1)
209
+ if op == 0:
210
+ return
211
+ bb = n.get("absoluteBoundingBox") or {}
212
+ for it in n.get("interactions") or []:
213
+ if (it.get("trigger") or {}).get("type") != "ON_HOVER":
214
+ continue
215
+ for act in it.get("actions") or []:
216
+ tr = act.get("transition") or {}
217
+ if bb and tr.get("duration"):
218
+ out.append({"x": round(bb["x"] - sx), "y": round(bb["y"] - sy),
219
+ "w": round(bb["width"]), "h": round(bb["height"]),
220
+ "ms": round(tr["duration"] * 1000),
221
+ "easing": (tr.get("easing") or {}).get("type"),
222
+ "dest": act.get("destinationId")})
223
+ for c in n.get("children", []) or []:
224
+ walk(c, op)
225
+
226
+ walk(node)
227
+ return out
228
+
229
+ def box_nodes(node, sx, sy):
230
+ """Non-text boxes worth checking: a visible solid fill and/or a corner radius.
231
+ Nothing here is about text, so it catches the class of defect the text audit
232
+ is blind to (a panel with the wrong colour, a card with the wrong radius)."""
233
+ out = []
234
+
235
+ def walk(n, op=1.0):
236
+ if n.get("visible") is False:
237
+ return
238
+ op *= n.get("opacity", 1)
239
+ if op == 0:
240
+ return
241
+ bb = n.get("absoluteBoundingBox") or {}
242
+ # TEXT nodes are the text audit's job; a text fill is a font colour, not a box.
243
+ if n.get("type") != "TEXT" and bb and bb.get("width", 0) >= 60 and bb.get("height", 0) >= 40:
244
+ fill = None
245
+ has_image = False
246
+ for f in n.get("fills") or []:
247
+ if f.get("visible") is False:
248
+ continue
249
+ if f.get("type") == "IMAGE":
250
+ has_image = True
251
+ elif f.get("type") == "SOLID" and fill is None \
252
+ and f.get("opacity", 1) >= 0.99 and f["color"].get("a", 1) >= 0.99:
253
+ c = f["color"]
254
+ fill = "#%02x%02x%02x" % tuple(round(c[k] * 255) for k in "rgb")
255
+ # An IMAGE fill covers the box: the solid underlay is invisible — never
256
+ # compare it against the CSS background colour.
257
+ if has_image:
258
+ fill = None
259
+ r = n.get("cornerRadius")
260
+ # Figma stores huge pill radii (e.g. 999/1353); the rendered radius can
261
+ # never exceed half the short side — clamp so comparisons are physical.
262
+ if r:
263
+ r = min(float(r), min(bb["width"], bb["height"]) / 2)
264
+ stroke = None
265
+ for st in n.get("strokes") or []:
266
+ if st.get("visible") is not False and st.get("type") == "SOLID" \
267
+ and st.get("opacity", 1) >= 0.5 and st["color"].get("a", 1) >= 0.5:
268
+ c = st["color"]
269
+ stroke = "#%02x%02x%02x" % tuple(round(c[k] * 255) for k in "rgb")
270
+ break
271
+ shadow = any(e.get("type") == "DROP_SHADOW" and e.get("visible") is not False
272
+ for e in n.get("effects") or [])
273
+ if fill or r or stroke or shadow:
274
+ out.append({"x": round(bb["x"] - sx), "y": round(bb["y"] - sy),
275
+ "w": round(bb["width"]), "h": round(bb["height"]),
276
+ "fill": fill, "radius": round(r) if r else None,
277
+ "stroke": stroke, "shadow": shadow})
278
+ for c in n.get("children", []) or []:
279
+ walk(c, op)
280
+
281
+ walk(node)
282
+ return out[:40]
283
+
284
+ def text_nodes(node, sx, sy):
285
+ """Every TEXT node that actually renders, with the spec we can verify in the DOM."""
286
+ out = []
287
+
288
+ def walk(n, op=1.0):
289
+ if n.get("visible") is False:
290
+ return
291
+ op *= n.get("opacity", 1)
292
+ if op == 0:
293
+ return
294
+ if n.get("type") == "TEXT":
295
+ bb = n.get("absoluteBoundingBox") or {}
296
+ st = n.get("style", {})
297
+ chars = (n.get("characters") or "").strip()
298
+ if bb and chars:
299
+ if st.get("textCase") == "UPPER":
300
+ chars = chars.upper()
301
+ out.append({
302
+ "text": " ".join(chars.split()),
303
+ "x": round(bb["x"] - sx), "y": round(bb["y"] - sy),
304
+ "w": round(bb["width"]), "h": round(bb["height"]),
305
+ "size": round(st.get("fontSize", 0)),
306
+ "weight": st.get("fontWeight"),
307
+ "family": st.get("fontFamily"),
308
+ "color": hexc(n),
309
+ # how the text sits in its box: a CENTER/RIGHT-aligned node in a wide
310
+ # fixed box has its ink offset from the box's left edge, so X must be
311
+ # compared at the matching anchor, not always at the left
312
+ "align": st.get("textAlignHorizontal"),
313
+ })
314
+ for c in n.get("children", []) or []:
315
+ walk(c, op)
316
+
317
+ walk(node)
318
+ return out
319
+
320
+ out = []
321
+ for name, (x, y, w, h) in boxes.items():
322
+ d = json.load(open(os.path.join(nodes_dir, f"{name}.json")))
323
+ d = d["document"] if "document" in d else d
324
+ cl, cr = content_extents(d, x, w)
325
+ icons, images = graphic_nodes(d, x, y, name)
326
+ out.append({"name": name, "top": round(y - origin_y), "left": round(x - origin_x),
327
+ "w": round(w), "h": round(h), "ref": refs[name],
328
+ "contentLeft": cl, "contentRight": cr,
329
+ "texts": text_nodes(d, x, y),
330
+ "boxes": box_nodes(d, x, y),
331
+ "hovers": hover_nodes(d, x, y),
332
+ "icons": icons, "images": images})
333
+ out.sort(key=lambda s: s["top"])
334
+ return out
335
+
336
+
337
+ HTML = r"""<!doctype html><meta charset="utf-8"><title>Fidelity report</title>
338
+ <style>
339
+ :root{--col:560px}
340
+ body{font:14px/1.5 system-ui,sans-serif;margin:0;background:#111;color:#eee}
341
+ header{padding:20px 24px;border-bottom:1px solid #333;position:sticky;top:0;background:#111;z-index:5}
342
+ h1{margin:0 0 4px;font-size:18px} .sub{color:#999}
343
+ #summary{margin-top:12px;display:flex;gap:22px;flex-wrap:wrap}
344
+ .stat{background:#1b1b1b;border:1px solid #333;border-radius:8px;padding:8px 14px}
345
+ .stat b{display:block;font-size:18px} .ok{color:#5cd08a} .bad{color:#ff7676}
346
+ section{padding:26px 24px;border-bottom:1px solid #262626}
347
+ .head{display:flex;align-items:baseline;gap:14px;margin-bottom:12px}
348
+ .head h2{margin:0;font-size:16px} .verdict{font-weight:600}
349
+ .cols{display:grid;grid-template-columns:repeat(2, var(--col)) ;gap:18px}
350
+ .pane{width:var(--col);background:#000;border:1px solid #333;border-radius:6px;overflow:hidden;position:relative}
351
+ .pane .cap{position:absolute;top:0;left:0;z-index:2;background:#000a;padding:3px 8px;font-size:11px;letter-spacing:.05em}
352
+ .shot{position:relative;overflow:hidden}
353
+ .shot img{display:block;width:100%}
354
+ .shot iframe{border:0;position:absolute;top:0;left:0;transform-origin:0 0}
355
+ .overlay{margin-top:18px}
356
+ .overlay .stack{width:var(--col);position:relative;overflow:hidden;border:1px solid #333;border-radius:6px}
357
+ .overlay img{display:block;width:100%}
358
+ .overlay .live{position:absolute;inset:0;overflow:hidden}
359
+ .overlay iframe{border:0;position:absolute;top:0;left:0;transform-origin:0 0}
360
+ .ctl{display:flex;align-items:center;gap:12px;margin:10px 0}
361
+ .ctl input[type=range]{width:260px}
362
+ table{border-collapse:collapse;margin-top:14px;font-size:13px;width:100%}
363
+ td,th{border:1px solid #333;padding:5px 9px;text-align:left}
364
+ th{background:#1b1b1b;font-weight:600}
365
+ code{color:#9fd}
366
+ </style>
367
+ <header>
368
+ <h1>Fidelity report</h1>
369
+ <div class="sub">Left = Figma reference · Right = the built page, live. Drag the slider to
370
+ cross-fade, or switch to <em>difference</em> — a perfect match goes black.</div>
371
+ <div id="summary"></div>
372
+ </header>
373
+ <main id="out"></main>
374
+ <script>
375
+ const SECTIONS = __SECTIONS__;
376
+ const UNVERIFIED = __UNVERIFIED__;
377
+ const BREAKPOINTS = __BREAKPOINTS__;
378
+ const ASSETS_MAP = __ASSETS_MAP__;
379
+ // Cache-bust every load of the page. The browser caches linked CSS/JS across iframes, so a
380
+ // report opened after an edit can silently measure the PREVIOUS stylesheet — a fixed offset
381
+ // stays "unfixed" in the report though the file on disk is correct. A unique query per report
382
+ // forces a fresh fetch of the page; sub-resources still cache unless the page links them with
383
+ // its own bust, so the report also appends the token to same-origin <link>/<script>/<img> in
384
+ // the probe before measuring.
385
+ const BUST = 'cb=' + (location.search.match(/[?&]v=([^&]+)/) || [,''])[1] + Date.now();
386
+ const PAGE = "__PAGE__" + ("__PAGE__".includes('?') ? '&' : '?') + BUST;
387
+ const DESIGN_W = __DESIGN_W__;
388
+ const COL_W = 560;
389
+ document.documentElement.style.setProperty('--col', COL_W + 'px');
390
+
391
+ const out = document.getElementById('out');
392
+ SECTIONS.forEach(s => {
393
+ const scale = COL_W / DESIGN_W;
394
+ const el = document.createElement('section');
395
+ el.innerHTML = `
396
+ <div class="head">
397
+ <h2>${s.name}</h2>
398
+ <span class="sub">Figma ${s.w}×${s.h} @ y=${s.top}</span>
399
+ <span class="verdict" id="v-${s.name}">measuring…</span>
400
+ </div>
401
+ <div class="cols">
402
+ <div class="pane"><span class="cap">Figma reference</span>
403
+ <div class="shot" style="height:${s.h*scale}px"><img src="${s.ref}"></div></div>
404
+ <div class="pane"><span class="cap">Built page (live)</span>
405
+ <div class="shot" style="height:${s.h*scale}px">
406
+ <iframe src="${PAGE}" scrolling="no" width="${DESIGN_W}" height="${s.top+s.h}"
407
+ style="transform:scale(${scale}) translateY(${-s.top}px)"></iframe>
408
+ </div></div>
409
+ </div>
410
+ <div class="overlay">
411
+ <div class="ctl">
412
+ <label>overlay <input type="range" min="0" max="100" value="50" id="r-${s.name}"></label>
413
+ <label><input type="checkbox" id="d-${s.name}"> difference blend</label>
414
+ </div>
415
+ <div class="stack" style="height:${s.h*scale}px">
416
+ <img src="${s.ref}">
417
+ <div class="live" id="l-${s.name}" style="opacity:.5">
418
+ <iframe src="${PAGE}" scrolling="no" width="${DESIGN_W}" height="${s.top+s.h}"
419
+ style="transform:scale(${scale}) translateY(${-s.top}px)"></iframe>
420
+ </div>
421
+ </div>
422
+ </div>`;
423
+ out.appendChild(el);
424
+ const live = el.querySelector(`#l-${CSS.escape(s.name)}`);
425
+ el.querySelector(`#r-${CSS.escape(s.name)}`).addEventListener('input', e => live.style.opacity = e.target.value/100);
426
+ el.querySelector(`#d-${CSS.escape(s.name)}`).addEventListener('change', e => {
427
+ live.style.mixBlendMode = e.target.checked ? 'difference' : 'normal';
428
+ live.style.opacity = e.target.checked ? 1 : (el.querySelector(`#r-${CSS.escape(s.name)}`).value/100);
429
+ });
430
+ });
431
+
432
+ // ---- numeric checks, run against the live page in an offscreen iframe ----
433
+ const probe = document.createElement('iframe');
434
+ probe.style.cssText = 'position:absolute;left:-9999px;width:' + DESIGN_W + 'px;height:900px';
435
+ probe.src = PAGE;
436
+ document.body.appendChild(probe);
437
+
438
+ // A verifier that dies quietly is worse than no verifier: the page keeps saying
439
+ // "measuring…" and the reader reads that as "still working", not "it crashed".
440
+ function reportCrash(err) {
441
+ const bar = document.createElement('div');
442
+ bar.style.cssText = 'position:sticky;top:0;z-index:99;background:#c0392b;color:#fff;'
443
+ + 'padding:14px 18px;font:13px/1.5 ui-monospace,monospace;white-space:pre-wrap';
444
+ bar.textContent = 'THIS REPORT CRASHED — every number below is missing, not passing.\n'
445
+ + (err && err.stack ? err.stack : String(err));
446
+ document.body.prepend(bar);
447
+ document.querySelectorAll('.verdict').forEach(v => { v.textContent = 'audit crashed'; });
448
+ }
449
+ window.addEventListener('error', e => reportCrash(e.error || e.message));
450
+ window.addEventListener('unhandledrejection', e => reportCrash(e.reason));
451
+
452
+ probe.addEventListener('load', async () => {
453
+ try {
454
+ const d = probe.contentDocument, w = probe.contentWindow;
455
+
456
+ // The probe page scrolls, so its vertical scrollbar steals ~15px from the layout viewport:
457
+ // every centred/right-anchored element then sits left of its Figma x and the whole text
458
+ // audit fails by a constant. Widen the iframe by the scrollbar width so the layout
459
+ // viewport is EXACTLY the design width.
460
+ const sbw = DESIGN_W - d.documentElement.clientWidth;
461
+ if (sbw > 0) probe.style.width = (DESIGN_W + sbw) + 'px';
462
+
463
+ // Force fresh CSS/JS. The HTML was loaded with a cache-bust, but its linked stylesheets are
464
+ // cached separately, so a just-fixed offset can still be measured against the OLD CSS. Re-
465
+ // point every same-origin <link rel=stylesheet>/<script>/<img> at a busted URL and wait for
466
+ // the stylesheets to re-apply before measuring — otherwise the report lies about the fix.
467
+ const busted = [];
468
+ d.querySelectorAll('link[rel="stylesheet"][href], script[src], img[src]').forEach(el => {
469
+ const attr = el.tagName === 'LINK' ? 'href' : 'src';
470
+ const u = el.getAttribute(attr);
471
+ if (!u || /^(https?:|data:|\/\/)/i.test(u) && !u.startsWith(location.origin)) return;
472
+ if (el.tagName === 'LINK') {
473
+ busted.push(new Promise(res => {
474
+ const link = el.cloneNode();
475
+ link.setAttribute('href', u + (u.includes('?') ? '&' : '?') + BUST);
476
+ link.addEventListener('load', res); link.addEventListener('error', res);
477
+ el.parentNode.insertBefore(link, el.nextSibling);
478
+ setTimeout(res, 1500); // never hang the whole report on one asset
479
+ }));
480
+ } else {
481
+ el.setAttribute(attr, u + (u.includes('?') ? '&' : '?') + BUST);
482
+ }
483
+ });
484
+ await Promise.all(busted);
485
+ await new Promise(r => setTimeout(r, 60)); // let the fresh CSS lay out
486
+
487
+ // Measure the RESOLVED layout, never a mid-animation frame. An entrance reveal commonly
488
+ // starts elements at opacity:0 translateY(Npx) and transitions them in. In this offscreen
489
+ // measuring iframe document.hidden is true, so CSS transitions and IntersectionObserver are
490
+ // FROZEN — every revealed element stays stranded at its start offset, and getComputedStyle
491
+ // returns that displaced, invisible value. Reading it makes a perfectly-built page look
492
+ // uniformly N-px low. Force every element to its final, settled state before measuring:
493
+ // kill transitions/animations AND neutralise the usual reveal start-state and its markers.
494
+ // This never hides a real design offset — it removes only the animation's own displacement.
495
+ const reset = d.createElement('style');
496
+ reset.textContent = `*,*::before,*::after{transition:none!important;animation:none!important}
497
+ [data-reveal],[data-aos],[class*="reveal"],[class*="fade"],[class*="animate"],
498
+ .is-in,.in-view,.is-visible,.visible,.show,.active{
499
+ opacity:1!important;transform:none!important;filter:none!important;
500
+ visibility:visible!important;clip-path:none!important}`;
501
+ d.documentElement.appendChild(reset);
502
+ // Some builds hide via a class on <html>/<body> removed by JS (e.g. .preload, .no-js);
503
+ // strip common ones so their descendants settle too.
504
+ ['preload','loading','no-js','js-loading'].forEach(c => {
505
+ d.documentElement.classList.remove(c); d.body && d.body.classList.remove(c);
506
+ });
507
+ void d.documentElement.offsetHeight; // force a synchronous reflow with the reset applied
508
+
509
+ const sel = __SELECTORS__;
510
+ const selProblems = [];
511
+ const nodes = sel
512
+ ? SECTIONS.map(s => {
513
+ const q = sel[s.name];
514
+ if (!q) { selProblems.push(`${s.name}: no selector`); return null; }
515
+ const hits = d.querySelectorAll(q);
516
+ if (hits.length !== 1) selProblems.push(`${s.name}: "${q}" matches ${hits.length} elements`);
517
+ return hits[0] || null;
518
+ })
519
+ : [...d.querySelectorAll('header, main > section, footer')];
520
+ if (!sel) selProblems.push('no --selectors given; sections were matched by DOM order, which can silently mis-map');
521
+
522
+ // Left/right edge of the real content, ignoring full-bleed backgrounds.
523
+ // Heights alone will not catch a container that is offset sideways.
524
+ function extents(root) {
525
+ const rb = root.getBoundingClientRect();
526
+ let lo = Infinity, hi = -Infinity;
527
+ root.querySelectorAll('*').forEach(e => {
528
+ const r = e.getBoundingClientRect();
529
+ if (r.width <= 0 || r.width >= rb.width * 0.95) return;
530
+ if (getComputedStyle(e).visibility === 'hidden') return;
531
+ // clamp into the frame: carousels legitimately overflow and are clipped
532
+ lo = Math.min(lo, Math.max(0, r.left - rb.left));
533
+ hi = Math.max(hi, Math.min(rb.width, r.right - rb.left));
534
+ });
535
+ return lo === Infinity ? [null, null] : [Math.round(lo), Math.round(hi)];
536
+ }
537
+
538
+ // ---- text-level audit: match Figma TEXT nodes to DOM elements by their words ----
539
+ const norm = t => (t||'').replace(/\s+/g,' ').trim();
540
+ const rgb2hex = c => {
541
+ const m = c.match(/\d+/g); if (!m) return c;
542
+ return '#' + m.slice(0,3).map(n => (+n).toString(16).padStart(2,'0')).join('');
543
+ };
544
+ // A Figma TEXT node may render as a bare text run inside a mixed element
545
+ // (e.g. "/ Night" beside a <span>), and the same string may appear many times
546
+ // (one per card). Collect every candidate run, then pick the one nearest to the
547
+ // position the design expects. Matching by element identity does neither.
548
+ // Figma's TEXT box top is the LINE box top — it includes the line-height leading above the
549
+ // caps. The browser's ink box (Range bounds) starts at the glyph tops, so ink-top carries a
550
+ // systematic bias vs Figma that swings with line-height (below for small text, ABOVE for big
551
+ // display type whose ascenders overshoot). The faithful analog is the element's own
552
+ // content-box top — but only when that element tightly wraps just this text; a multi-child
553
+ // container's top is the first child's, not this run's. So: content-box top when the element
554
+ // wraps exactly `want`, else fall back to the ink line. X always uses the ink's left edge.
555
+ function boxTop(el) {
556
+ const r = el.getBoundingClientRect(), cs = el.ownerDocument.defaultView.getComputedStyle(el);
557
+ return r.top + parseFloat(cs.paddingTop || 0) + parseFloat(cs.borderTopWidth || 0);
558
+ }
559
+ function yTop(el, rg, want) {
560
+ return norm(el.textContent).toLowerCase() === want ? boxTop(el)
561
+ : (rg.getClientRects()[0] || rg.getBoundingClientRect()).top;
562
+ }
563
+ function candidates(root, text) {
564
+ const want = norm(text).toLowerCase();
565
+ const out = [];
566
+ const w = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_TEXT);
567
+ for (let n = w.nextNode(); n; n = w.nextNode()) {
568
+ if (norm(n.nodeValue).toLowerCase() === want && n.parentElement) {
569
+ // Text inside a closed <option> has no layout box (rect 0,0); if kept it wins the
570
+ // leaf-most filter over the real <select> and the field reads "not found". The
571
+ // select itself is matched by the form-control pass below.
572
+ if (n.parentElement.closest('option')) continue;
573
+ const rg = root.ownerDocument.createRange();
574
+ rg.selectNodeContents(n);
575
+ out.push({el: n.parentElement, rect: rg.getBoundingClientRect(), top: yTop(n.parentElement, rg, want)});
576
+ }
577
+ }
578
+ // also whole elements whose text EQUALS the wanted string, for text broken across <br>
579
+ // or nested spans. Use innerText, not textContent: a <br> joins two runs with no space in
580
+ // textContent ("Best availabledirect rates"), so a design string with the space would
581
+ // never match. innerText renders the <br> as a line break, which norm() folds to a space.
582
+ // Only EQUALS here — a CONTAINS match (e.g. "5" inside "5 Bedrooms") must NOT use the whole
583
+ // element's rect (it starts at the element's left, e.g. an icon), which reports a large
584
+ // false x-offset. Those are handled by the substring fallback below, which ranges over the
585
+ // matched substring so the position is the substring's own, not the container's.
586
+ root.querySelectorAll('*').forEach(e => {
587
+ // Skip <option>: it has no layout box (rect 0,0) but its innerText matches the select's
588
+ // placeholder, so it would survive the leaf-most filter over the real <select> and then
589
+ // be discarded as zero-size, leaving the field "not found". The select is matched by its
590
+ // value in the form-control pass below.
591
+ if (e.tagName === 'OPTION') return;
592
+ if (norm(e.innerText).toLowerCase() !== want) return;
593
+ const rg = root.ownerDocument.createRange();
594
+ rg.selectNodeContents(e);
595
+ out.push({el: e, rect: rg.getBoundingClientRect(), top: yTop(e, rg, want)});
596
+ });
597
+ // A Figma TEXT node inside a field ("First Name", "Email") usually renders as a form
598
+ // control's placeholder/value/label, not as page text. Reading only text nodes would
599
+ // cry "missing" over copy that is plainly on screen.
600
+ // NB: never match a bare <option> — inside a closed <select> it has no layout box (rect
601
+ // at 0,0), and the leaf-most filter would then drop the real <select> in its favour and
602
+ // report a nonsense −thousands-px offset. The select's own `value` already carries the
603
+ // placeholder/first-option text, so the control matches without the option.
604
+ root.querySelectorAll('input, textarea, select, [aria-label], [placeholder]')
605
+ .forEach(e => {
606
+ const cand = [e.getAttribute('placeholder'), e.getAttribute('aria-label'), e.value,
607
+ e.tagName === 'SELECT' && e.options[e.selectedIndex]
608
+ ? e.options[e.selectedIndex].textContent : null];
609
+ if (!cand.some(v => norm(v).toLowerCase() === want)) return;
610
+ const br = e.getBoundingClientRect();
611
+ // The design's node is the placeholder/label TEXT, which sits inside the field's
612
+ // content padding — not at the field box's edge. Anchor X/Y at the content box so the
613
+ // comparison matches where the text actually renders, not the control's border.
614
+ // X only: a single-line field's placeholder is centred vertically, so the box top is
615
+ // already the right Y anchor; only the horizontal inset (padding/border) matters.
616
+ const fcs = e.ownerDocument.defaultView.getComputedStyle(e);
617
+ const pad = n => parseFloat(fcs[n] || 0);
618
+ const cr = {left: br.left + pad('paddingLeft') + pad('borderLeftWidth'),
619
+ right: br.right - pad('paddingRight') - pad('borderRightWidth'),
620
+ top: br.top,
621
+ width: br.width - pad('paddingLeft') - pad('paddingRight')};
622
+ out.push({el: e, rect: cr, top: br.top, viaAttr: true});
623
+ });
624
+ // Fallback — the design may split one phrase into several TEXT nodes ("5", "Bedrooms")
625
+ // that the build renders as one run ("5 Bedrooms"), or the reverse. A run that CONTAINS
626
+ // the wanted text at word boundaries is a visual match, not a miss. Range over just the
627
+ // substring so its own rect is used and position is still checked. Only when no exact
628
+ // candidate was found, so the common case stays precise.
629
+ if (!out.length && want) {
630
+ const rx = new RegExp('(^|\\s)' + want.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
631
+ .replace(/\s+/g, '\\s+') + '(\\s|$)', 'i');
632
+ const wk = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_TEXT);
633
+ for (let n = wk.nextNode(); n; n = wk.nextNode()) {
634
+ if (!n.parentElement) continue;
635
+ const m = rx.exec(n.nodeValue || '');
636
+ if (!m) continue;
637
+ const start = m.index + m[1].length;
638
+ const rg = root.ownerDocument.createRange();
639
+ rg.setStart(n, start);
640
+ rg.setEnd(n, start + (m[0].length - m[1].length - m[2].length));
641
+ out.push({el: n.parentElement, rect: rg.getBoundingClientRect(), top: (rg.getClientRects()[0]||rg.getBoundingClientRect()).top, grouped: true});
642
+ }
643
+ }
644
+ // A label wrapped in a group wrapped in a field all share the same innerText, so all three
645
+ // become candidates — but only the innermost is the actual text box; the outer ones are
646
+ // taller and sit higher, which would report a false vertical offset. Keep only the
647
+ // leaf-most: drop any candidate whose element contains another candidate's element.
648
+ const els = out.map(c => c.el);
649
+ return out.filter(c => !els.some(o => o !== c.el && c.el.contains(o)));
650
+ }
651
+ function pickNearest(cands, box, rb) {
652
+ let best = null, bd = Infinity;
653
+ cands.forEach(c => {
654
+ if (!c.rect.width && !c.rect.height) return;
655
+ // X from the ink's left edge; Y from the line-box top (the true analog of Figma's box).
656
+ const dx = (c.rect.left - rb.left) - box.x;
657
+ const dy = ((c.top != null ? c.top : c.rect.top) - rb.top) - box.y;
658
+ const d = Math.hypot(dx, dy);
659
+ if (d < bd) { bd = d; best = c; }
660
+ });
661
+ return best;
662
+ }
663
+ // Measure the *ink box* of the text, not the element box: a centred heading lives in a
664
+ // full-width <p>, while Figma stores the tight text box. Comparing element rects would
665
+ // report a huge false offset.
666
+ function inkRect(el) {
667
+ const rg = el.ownerDocument.createRange();
668
+ rg.selectNodeContents(el);
669
+ const r = rg.getBoundingClientRect();
670
+ rg.detach && rg.detach();
671
+ return (r.width || r.height) ? r : el.getBoundingClientRect();
672
+ }
673
+ const SUBSTITUTED = new Set(); // font families we could not load -> weight is not comparable
674
+
675
+ const textIssues = [];
676
+ // One "10/224 match" number hides the story: a string that is present and correct except
677
+ // for a 6px shift is nothing like a string that is missing. Tally each dimension so the
678
+ // reader sees "223 found, 61 positioned, ..." and knows a shifted layout from dropped copy.
679
+ const tally = {found:0, position:0, size:0, weight:0, colour:0};
680
+ function auditText(sec, node) {
681
+ const rb = node.getBoundingClientRect();
682
+ (sec.texts || []).forEach(t => {
683
+ const hit = pickNearest(candidates(node, t.text), t, rb);
684
+ if (!hit) { textIssues.push({s:sec.name, t:t.text.slice(0,34), issue:'text not found in DOM'}); return; }
685
+ tally.found++;
686
+ const el = hit.el;
687
+ const r = hit.rect, cs = getComputedStyle(el);
688
+ // Compare X at the anchor the text is aligned to. Figma's box is the LAYOUT box; a
689
+ // CENTER/RIGHT-aligned node's ink sits away from the box's left edge, so comparing my
690
+ // ink-left to the Figma box-left invents an offset the size of the box's slack (seen as
691
+ // +195 on a centred name in a full-column box). Anchor both sides the same way.
692
+ let dx;
693
+ if (t.align === 'CENTER') dx = Math.round((r.left + r.width / 2 - rb.left) - (t.x + t.w / 2));
694
+ else if (t.align === 'RIGHT') dx = Math.round((r.right - rb.left) - (t.x + t.w));
695
+ else dx = Math.round(r.left - rb.left) - t.x;
696
+ const dy = Math.round((hit.top != null ? hit.top : r.top) - rb.top) - t.y;
697
+ const fs = Math.round(parseFloat(cs.fontSize));
698
+ const fw = parseInt(cs.fontWeight, 10);
699
+ // A design TEXT that renders as an input's PLACEHOLDER shows the ::placeholder colour,
700
+ // not the element's `color` (which paints typed text). Comparing `color` would flag a
701
+ // grey placeholder as wrong just because typed text is dark. Read the pseudo when the
702
+ // match came from an attribute on a form field.
703
+ let col = rgb2hex(cs.color);
704
+ if (hit.viaAttr && /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName)) {
705
+ try {
706
+ const ph = rgb2hex(getComputedStyle(el, '::placeholder').color);
707
+ if (ph && ph !== '#') col = ph;
708
+ } catch (e) {}
709
+ }
710
+ const famUsed = (cs.fontFamily.split(',')[0] || '').replace(/["']/g, '').trim();
711
+ // A declared @font-face whose file 404s still shows up in computed fontFamily.
712
+ // Ask the document whether the face is really loaded before trusting it.
713
+ let loaded = true;
714
+ try { loaded = el.ownerDocument.fonts.check(`${t.weight || 400} ${t.size || 16}px "${famUsed}"`); } catch (e) {}
715
+ const substituted = !!t.family &&
716
+ (famUsed.toLowerCase() !== t.family.toLowerCase() || !loaded);
717
+ if (substituted) SUBSTITUTED.add(`${t.family}${loaded ? ' -> ' + famUsed : ' (not loaded)'}`);
718
+
719
+ const bad = [];
720
+ const posOK = Math.abs(dx) <= 4 && Math.abs(dy) <= 4;
721
+ if (Math.abs(dx) > 4) bad.push(`x ${dx>0?'+':''}${dx}`);
722
+ if (Math.abs(dy) > 4) bad.push(`y ${dy>0?'+':''}${dy}`);
723
+ if (posOK) tally.position++;
724
+ const sizeOK = !t.size || Math.abs(fs - t.size) <= 1;
725
+ if (!sizeOK) bad.push(`size ${fs} vs ${t.size}`);
726
+ if (sizeOK) tally.size++;
727
+ // weight is only comparable within the same typeface
728
+ const weightOK = substituted || !t.weight || Math.abs(fw - t.weight) < 100;
729
+ if (!weightOK) bad.push(`weight ${fw} vs ${t.weight}`);
730
+ if (weightOK) tally.weight++;
731
+ const colOK = !t.color || col.toLowerCase() === t.color.toLowerCase();
732
+ if (!colOK) bad.push(`colour ${col} vs ${t.color}`);
733
+ if (colOK) tally.colour++;
734
+ if (bad.length) textIssues.push({s:sec.name, t:t.text.slice(0,34), issue:bad.join(', ')});
735
+ });
736
+ }
737
+
738
+ // ---- graphic audit: is each design icon a real exported asset, a hand-drawn
739
+ // approximation, or missing? Same for photos vs gradient placeholders. ----
740
+ const gfx = {icon:{asset:0, drawn:0, missing:0, offset:0, wrong:0, extra:0, unsigned:0}, image:{real:0, placeholder:0, missing:0}};
741
+ const identityChecks = [];
742
+ const gfxIssues = [];
743
+ // Returns {el, d} for the closest candidate, whatever the distance. The caller decides
744
+ // what counts as a match: "nothing is there" and "something is there, 90px off" are two
745
+ // different defects and must never be reported with the same word.
746
+ function nearest(cands, box, rb) {
747
+ const cx = box.x + box.w/2, cy = box.y + box.h/2;
748
+ let best = null, bd = 1e9;
749
+ cands.forEach(e => {
750
+ const r = e.getBoundingClientRect();
751
+ if (!r.width) return;
752
+ const ex = r.left - rb.left + r.width/2, ey = r.top - rb.top + r.height/2;
753
+ const d = Math.hypot(ex - cx, ey - cy);
754
+ if (d < bd) { bd = d; best = e; }
755
+ });
756
+ return best ? {el: best, d: Math.round(bd)} : null;
757
+ }
758
+ const ICONS_DIR = __ICONS_DIR__;
759
+ const MANIFEST_FILES = __MANIFEST__;
760
+ const sigCache = new Map();
761
+ // figma_icons.py stamps every icon it exports with data-icon-shape (a scale- and
762
+ // translation-invariant outline profile) and data-icon-paint. Comparing files by name or
763
+ // by bytes would call each legitimate reuse of an icon a mismatch; comparing geometry
764
+ // alone would call a filled star and an outlined star the same icon.
765
+ function signature(url) {
766
+ if (sigCache.has(url)) return sigCache.get(url);
767
+ const pr = fetch(url).then(r => r.ok ? r.text() : null).then(t => {
768
+ if (!t) return null;
769
+ const el = new DOMParser().parseFromString(t, 'image/svg+xml').documentElement;
770
+ const shape = el.getAttribute('data-icon-shape');
771
+ if (!shape) return null;
772
+ return {shape: shape.split(',').map(Number), paint: el.getAttribute('data-icon-paint')};
773
+ }).catch(() => null);
774
+ sigCache.set(url, pr);
775
+ return pr;
776
+ }
777
+ function sameIcon(a, b, eps = 0.02) {
778
+ if (!a || !b || a.paint !== b.paint || a.shape.length !== b.shape.length) return false;
779
+ return a.shape.every((v, i) => Math.abs(v - b.shape[i]) < eps);
780
+ }
781
+
782
+ const identityIssues = [];
783
+ const extraGraphics = [];
784
+
785
+ // Verify the verifier. A comparator that answers "different" to everything makes a
786
+ // perfect build look broken, and one that answers "same" makes a broken build look
787
+ // perfect. Prove it can tell the same file from itself before trusting a single verdict.
788
+ const comparatorOK = !ICONS_DIR ? Promise.resolve(true) : (async () => {
789
+ const probe = Object.values(MANIFEST_FILES)[0];
790
+ if (!probe) return true;
791
+ const a = await signature(ICONS_DIR + '/' + probe + '?a');
792
+ const b = await signature(ICONS_DIR + '/' + probe + '?b');
793
+ if (!a || !b) return true; // unsigned icons: nothing to test
794
+ if (!sameIcon(a, b)) {
795
+ reportCrash(new Error('icon comparator says a file differs from itself — '
796
+ + 'every "wrong icon" row below is a lie'));
797
+ return false;
798
+ }
799
+ return true;
800
+ })();
801
+
802
+ function auditGraphics(sec, node) {
803
+ const rb = node.getBoundingClientRect();
804
+ // src*= not src$= — the report's own cache-bust pass appends ?BUST to every img src.
805
+ // Small raster images count too: wordmark logos are legitimately PNG (vector extraction
806
+ // can collapse them), and excluding them reads as "icon absent".
807
+ const svgs = [...node.querySelectorAll('svg, img')].filter(e => {
808
+ const src = e.tagName === 'IMG' ? (e.getAttribute('src') || '') : '';
809
+ if (e.tagName !== 'IMG' || src.includes('.svg')) return true;
810
+ const r = e.getBoundingClientRect();
811
+ // wordmark shape only: wide and short. Square/portrait rasters are photos/tiles.
812
+ return r.width <= 400 && r.height <= 120 && r.width >= 2 * r.height;
813
+ });
814
+ const claimed = new Set();
815
+ (sec.icons || []).forEach(b => {
816
+ const tol = Math.max(24, b.w);
817
+ const hit = nearest(svgs, b, rb);
818
+ if (!hit) {
819
+ gfx.icon.missing++;
820
+ gfxIssues.push({s:sec.name, k:'icon', m:`nothing drawn at ${b.x},${b.y} (${b.w}x${b.h})`});
821
+ return;
822
+ }
823
+ if (hit.d > tol) {
824
+ claimed.add(hit.el); // already reported as misplaced; not also "extra"
825
+ gfx.icon.offset++;
826
+ gfxIssues.push({s:sec.name, k:'icon',
827
+ m:`${b.w}x${b.h} icon belongs at ${b.x},${b.y}; nearest ${hit.el.tagName.toLowerCase()}`
828
+ + ` is ${hit.d}px away (tolerance ${tol}px)`});
829
+ return;
830
+ }
831
+ claimed.add(hit.el);
832
+ if (hit.el.tagName === 'IMG') {
833
+ gfx.icon.asset++;
834
+ // ...but is it the RIGHT icon? Compare what it draws against what the design node
835
+ // exported. "An icon is present" and "the icon belongs here" are different claims.
836
+ if (ICONS_DIR && b.file) {
837
+ identityChecks.push(Promise.all([
838
+ signature(hit.el.getAttribute('src')),
839
+ signature(ICONS_DIR + '/' + b.file),
840
+ ]).then(([got, want]) => {
841
+ if (!want || !got) return;
842
+ if (!sameIcon(got, want)) {
843
+ gfx.icon.wrong++;
844
+ identityIssues.push({s: sec.name, k: 'icon',
845
+ m: `${hit.el.getAttribute('src').split('/').pop()} at ${b.x},${b.y} is not the icon `
846
+ + `the design puts there (${b.file})`});
847
+ }
848
+ }));
849
+ }
850
+ }
851
+ else { gfx.icon.drawn++; gfxIssues.push({s:sec.name, k:'icon', m:`hand-drawn inline <svg> at ${b.x},${b.y}`}); }
852
+ });
853
+
854
+ // ---- reverse audit: graphics the design does not have ----
855
+ // Every check above walks design -> DOM, so anything you ADD to the page is invisible
856
+ // to it. Walk DOM -> design as well, or a stray icon ships looking verified.
857
+ svgs.forEach(e => {
858
+ if (claimed.has(e)) return;
859
+ const r = e.getBoundingClientRect();
860
+ if (!r.width || !r.height) return; // display:none, e.g. the closed state of a toggle
861
+ gfx.icon.extra++;
862
+ const src = e.tagName === 'IMG' ? e.getAttribute('src').split('/').pop() : 'inline <svg>';
863
+ extraGraphics.push({s: sec.name, k: 'icon',
864
+ m: `${src} at ${Math.round(r.left - rb.left)},${Math.round(r.top - rb.top)} `
865
+ + `has no icon node in the design`});
866
+ });
867
+ const pics = [...node.querySelectorAll('img:not([src$=".svg"]), [style*="background"], *')]
868
+ .filter(e => { const bi = getComputedStyle(e).backgroundImage;
869
+ return e.tagName === 'IMG' || (bi && bi !== 'none'); });
870
+ (sec.images || []).forEach(b => {
871
+ const pick = nearest(pics, b, rb);
872
+ const el = (pick && pick.d <= Math.max(24, b.w)) ? pick.el : null;
873
+ if (!el) { gfx.image.missing++; gfxIssues.push({s:sec.name, k:'image', m:`missing at ${b.x},${b.y}`}); return; }
874
+ const bi = getComputedStyle(el).backgroundImage;
875
+ const real = el.tagName === 'IMG' ? !!el.currentSrc : /url\(/.test(bi);
876
+ if (real) gfx.image.real++;
877
+ else { gfx.image.placeholder++; gfxIssues.push({s:sec.name, k:'image', m:`gradient placeholder at ${b.x},${b.y}`}); }
878
+ });
879
+ }
880
+
881
+ // ---- box audit: fill colour and corner radius of non-text elements ----
882
+ const boxIssues = []; // real mismatches: wrong fill or wrong radius
883
+ const boxUnmatched = []; // Figma frame with no 1:1 DOM element — usually structural, not a bug
884
+ function auditBoxes(sec, node) {
885
+ const rb = node.getBoundingClientRect();
886
+ const all = [node, ...node.querySelectorAll('*')]; // the section itself is a box too
887
+ (sec.boxes || []).forEach(b => {
888
+ let best = null, bd = Infinity;
889
+ all.forEach(e => {
890
+ const r = e.getBoundingClientRect();
891
+ if (Math.abs(r.width - b.w) > 4 || Math.abs(r.height - b.h) > 4) return;
892
+ const d = Math.hypot((r.left - rb.left) - b.x, (r.top - rb.top) - b.y);
893
+ if (d < bd) { bd = d; best = e; }
894
+ });
895
+ if (!best || bd > 8) { boxUnmatched.push({s:sec.name, m:`${b.w}x${b.h} at ${b.x},${b.y}`}); return; }
896
+ const cs = getComputedStyle(best);
897
+ const bestR = best.getBoundingClientRect();
898
+ if (b.fill) {
899
+ // A CSS background-image (photo/gradient) covers the colour exactly like a Figma
900
+ // IMAGE fill does — comparing the underlay colour would be a guaranteed false fail.
901
+ const covered = (cs.backgroundImage && cs.backgroundImage !== 'none') || best.tagName === 'IMG';
902
+ let bg = rgb2hex(cs.backgroundColor);
903
+ // Transparent element: the visible paint may live on an inset-0 ::before (e.g. a
904
+ // flipped background layer) — read that before crying mismatch.
905
+ if (!covered && (bg === null || cs.backgroundColor === 'rgba(0, 0, 0, 0)')) {
906
+ const pb = getComputedStyle(best, '::before');
907
+ if (pb.backgroundImage && pb.backgroundImage !== 'none') bg = 'covered';
908
+ else if (pb.backgroundColor && pb.backgroundColor !== 'rgba(0, 0, 0, 0)') bg = rgb2hex(pb.backgroundColor);
909
+ else {
910
+ // A child <img>/<video> filling ≥80% of the box paints it (badge/ring photos)
911
+ const kid = [...best.children].find(k => (k.tagName === 'IMG' || k.tagName === 'VIDEO')
912
+ && k.getBoundingClientRect().width * k.getBoundingClientRect().height
913
+ >= 0.8 * bestR.width * bestR.height);
914
+ if (kid) bg = 'covered';
915
+ }
916
+ }
917
+ if (!covered && bg !== 'covered' && (bg || '').toLowerCase() !== b.fill.toLowerCase())
918
+ boxIssues.push({s:sec.name, m:`${b.w}x${b.h} fill ${bg} vs ${b.fill}`});
919
+ }
920
+ if (b.radius != null) {
921
+ // Resolve % radii against the element's own box, and clamp both sides to half the
922
+ // short side — 50% on a 108px tile and Figma's 999-style pill are the SAME circle.
923
+ const raw = cs.borderTopLeftRadius || '0';
924
+ let rr = raw.trim().endsWith('%')
925
+ ? parseFloat(raw) / 100 * Math.min(bestR.width, bestR.height)
926
+ : parseFloat(raw) || 0;
927
+ const half = Math.min(b.w, b.h) / 2;
928
+ rr = Math.round(Math.min(rr, half));
929
+ const want = Math.round(Math.min(b.radius, half));
930
+ if (Math.abs(rr - want) > 1)
931
+ boxIssues.push({s:sec.name, m:`${b.w}x${b.h} radius ${rr} vs ${want}`});
932
+ }
933
+ if (b.stroke) {
934
+ // A design stroke may render as any one border side (e.g. a bottom accent bar)
935
+ const side = ['Top','Right','Bottom','Left'].find(x => parseFloat(cs['border'+x+'Width']) > 0);
936
+ const bw = side ? parseFloat(cs['border'+side+'Width']) : 0;
937
+ const bc = bw ? rgb2hex(cs['border'+side+'Color']) : null;
938
+ if (!bw) boxIssues.push({s:sec.name, m:`${b.w}x${b.h} has no border; design strokes it ${b.stroke}`});
939
+ else if (bc.toLowerCase() !== b.stroke.toLowerCase())
940
+ boxIssues.push({s:sec.name, m:`${b.w}x${b.h} border ${bc} vs ${b.stroke}`});
941
+ }
942
+ if (b.shadow && cs.boxShadow === 'none')
943
+ boxIssues.push({s:sec.name, m:`${b.w}x${b.h} has no box-shadow; design has a drop shadow`});
944
+ });
945
+ }
946
+
947
+ // ---- motion audit: the design states hover durations; verify what you shipped ----
948
+ const motionIssues = [];
949
+ let motionChecked = 0;
950
+ // Element lookup runs now (geometry is frozen and reliable), but the DURATION read is
951
+ // DEFERRED: the measuring reset above sets `transition:none!important` on everything, so
952
+ // reading transitionDuration here would report "no transition" for a perfectly-animated
953
+ // build — the verifier must not measure what it has itself disabled.
954
+ const motionQueue = [];
955
+ function auditMotion(sec, node) {
956
+ const rb = node.getBoundingClientRect();
957
+ const all = [node, ...node.querySelectorAll('*')];
958
+ (sec.hovers || []).forEach(hv => {
959
+ let best = null, bd = Infinity;
960
+ all.forEach(e => {
961
+ const r = e.getBoundingClientRect();
962
+ if (Math.abs(r.width - hv.w) > 6 || Math.abs(r.height - hv.h) > 6) return;
963
+ const d = Math.hypot((r.left - rb.left) - hv.x, (r.top - rb.top) - hv.y);
964
+ if (d < bd) { bd = d; best = e; }
965
+ });
966
+ if (!best || bd > 8) return; // element not found: geometry audits cover it
967
+ // Wrappers, the interactive element and its icon often share the exact box; keep the
968
+ // whole tie set and let the duration read pick whichever actually carries a transition.
969
+ const ties = all.filter(e => {
970
+ const r = e.getBoundingClientRect();
971
+ if (Math.abs(r.width - hv.w) > 6 || Math.abs(r.height - hv.h) > 6) return false;
972
+ return Math.hypot((r.left - rb.left) - hv.x, (r.top - rb.top) - hv.y) <= bd + 1;
973
+ });
974
+ motionQueue.push({sec: sec.name, hv, els: ties.length ? ties : [best]});
975
+ });
976
+ }
977
+ function flushMotion() {
978
+ // Steady-state first: in this hidden iframe the entrance reveal never fires, so its
979
+ // marker attribute (and its own 820ms transition rule) is still on every element and
980
+ // would shadow the hover transition being audited. The live page sheds the marker
981
+ // after the entrance completes — measure that state.
982
+ d.querySelectorAll('[data-reveal], [data-aos]').forEach(e => {
983
+ e.removeAttribute('data-reveal'); e.removeAttribute('data-aos');
984
+ e.classList.remove('is-in', 'in-view', 'is-visible');
985
+ });
986
+ reset.remove(); // un-freeze transitions
987
+ void d.documentElement.offsetHeight;
988
+ motionQueue.forEach(({sec, hv, els}) => {
989
+ motionChecked++;
990
+ const ms = Math.max(...els.map(el => {
991
+ const cs = getComputedStyle(el);
992
+ return Math.max(...cs.transitionDuration.split(',').map(v => parseFloat(v) * 1000 || 0));
993
+ }));
994
+ if (ms === 0)
995
+ motionIssues.push({s:sec, m:`${hv.w}x${hv.h} has no transition; design says ${hv.ms}ms ${hv.easing}`});
996
+ else if (Math.abs(ms - hv.ms) > 30)
997
+ motionIssues.push({s:sec, m:`${hv.w}x${hv.h} transition ${Math.round(ms)}ms vs ${hv.ms}ms (${hv.easing})`});
998
+ });
999
+ d.documentElement.appendChild(reset); // re-freeze for anything after
1000
+ void d.documentElement.offsetHeight;
1001
+ }
1002
+
1003
+ // ---- image identity: is the RIGHT photo wired to this node, not merely a photo? ----
1004
+ // (icon identity shares the identityIssues list declared with the graphics audit)
1005
+ let identityChecked = 0;
1006
+ function auditIdentity(sec, node) {
1007
+ if (!ASSETS_MAP) return;
1008
+ const rb = node.getBoundingClientRect();
1009
+ // only elements that actually CARRY an image can be identity candidates — matching
1010
+ // "nearest of everything" lands on wrappers/overlays and reads "uses none"
1011
+ const pics = [...node.querySelectorAll('*')].filter(e =>
1012
+ e.tagName === 'IMG' || getComputedStyle(e).backgroundImage !== 'none');
1013
+ // Overlapping design variants often stamp a TEMPLATE's image over the real card's
1014
+ // position: several design entries share one spot with different refs. Group them —
1015
+ // the build passes if it uses ANY of that spot's refs.
1016
+ const spots = new Map();
1017
+ (sec.images || []).forEach(im => {
1018
+ if (!im.ref || !ASSETS_MAP[im.ref]) return;
1019
+ const k = Math.round(im.x/8) + ':' + Math.round(im.y/8);
1020
+ if (!spots.has(k)) spots.set(k, {x: im.x, y: im.y, wants: new Set()});
1021
+ spots.get(k).wants.add(ASSETS_MAP[im.ref]);
1022
+ });
1023
+ spots.forEach(im => {
1024
+ const wants = [...im.wants];
1025
+ const want = wants.join(' | ');
1026
+ let best = null, bd = Infinity;
1027
+ pics.forEach(e => {
1028
+ const r = e.getBoundingClientRect();
1029
+ if (!r.width) return;
1030
+ const d = Math.hypot((r.left - rb.left) - im.x, (r.top - rb.top) - im.y);
1031
+ if (d < bd) { bd = d; best = e; }
1032
+ });
1033
+ if (!best || bd > 12) return;
1034
+ identityChecked++;
1035
+ const src = best.tagName === 'IMG' ? (best.getAttribute('src') || '')
1036
+ : getComputedStyle(best).backgroundImage;
1037
+ if (!wants.some(w => src.includes(w)))
1038
+ identityIssues.push({s:sec.name, m:`node expects ${want}, element uses ${src.slice(0,60)}`});
1039
+ });
1040
+ }
1041
+
1042
+ let pass = 0, fail = 0, rows = '';
1043
+ SECTIONS.forEach((s, i) => {
1044
+ const n = nodes[i];
1045
+ const built = n ? Math.round(n.getBoundingClientRect().height) : null;
1046
+ const [bl, br] = n ? extents(n) : [null, null];
1047
+ const hOK = built === s.h;
1048
+ const TOL = 2;
1049
+ const xOK = s.contentLeft === null || bl === null
1050
+ || (Math.abs(bl - s.contentLeft) <= TOL && Math.abs(br - s.contentRight) <= TOL);
1051
+ if (n) { auditText(s, n); auditGraphics(s, n); auditBoxes(s, n); auditMotion(s, n); auditIdentity(s, n); }
1052
+ const ok = hOK && xOK;
1053
+ ok ? pass++ : fail++;
1054
+
1055
+ const notes = [];
1056
+ if (!hOK) notes.push(`height ${built}px vs ${s.h}px (${built - s.h > 0 ? '+' : ''}${built - s.h})`);
1057
+ if (!xOK) notes.push(`content x ${bl}–${br} vs ${s.contentLeft}–${s.contentRight}`);
1058
+ const v = document.getElementById('v-' + s.name);
1059
+ v.textContent = built === null ? 'no element matched' : (ok ? 'matches' : notes.join(' · '));
1060
+ v.className = 'verdict ' + (ok ? 'ok' : 'bad');
1061
+
1062
+ rows += `<tr><td>${s.name}</td>
1063
+ <td class="${hOK?'ok':'bad'}">${s.h} / ${built ?? '—'}</td>
1064
+ <td class="${xOK?'ok':'bad'}">${s.contentLeft ?? '—'}–${s.contentRight ?? '—'} / ${bl ?? '—'}–${br ?? '—'}</td>
1065
+ <td class="${ok?'ok':'bad'}">${ok ? 'match' : 'differs'}</td></tr>`;
1066
+ });
1067
+ flushMotion(); // duration reads must run OUTSIDE the transition-freezing reset
1068
+
1069
+ // Blank-graphic scan: the per-node audits confirm an image is PRESENT, never that it
1070
+ // renders as anything. A logo whose vector extraction collapsed into a solid rectangle
1071
+ // (e.g. a wordmark exported as overlapping filled paths) passes every other check while
1072
+ // showing an empty block. Draw each sizeable same-origin image and flag it if it is a
1073
+ // near-uniform opaque block (a shaped icon has transparent surroundings, so it is exempt).
1074
+ const blankImgs = [];
1075
+ d.querySelectorAll('img').forEach(img => {
1076
+ const r = img.getBoundingClientRect();
1077
+ if (r.width < 60 || r.height < 12 || !img.complete || !img.naturalWidth) return;
1078
+ try {
1079
+ const cv = document.createElement('canvas');
1080
+ const W = cv.width = Math.min(64, img.naturalWidth), H = cv.height = Math.min(64, img.naturalHeight);
1081
+ const cx = cv.getContext('2d');
1082
+ cx.drawImage(img, 0, 0, W, H);
1083
+ const px = cx.getImageData(0, 0, W, H).data;
1084
+ const counts = new Map(); let opaque = 0;
1085
+ for (let i = 0; i < px.length; i += 4) {
1086
+ if (px[i + 3] < 128) continue;
1087
+ opaque++;
1088
+ const k = (px[i] >> 4) + ',' + (px[i + 1] >> 4) + ',' + (px[i + 2] >> 4);
1089
+ counts.set(k, (counts.get(k) || 0) + 1);
1090
+ }
1091
+ const total = W * H;
1092
+ const top = Math.max(0, ...counts.values());
1093
+ if (opaque / total > 0.85 && top / opaque > 0.9) {
1094
+ blankImgs.push({src: (img.getAttribute('src') || '').split('/').pop(),
1095
+ wh: `${Math.round(r.width)}x${Math.round(r.height)}`});
1096
+ }
1097
+ } catch (e) { /* cross-origin: cannot inspect */ }
1098
+ });
1099
+
1100
+ const overflow = d.documentElement.scrollWidth > DESIGN_W + 1;
1101
+ const totalFigma = SECTIONS.reduce((a, s) => a + s.h, 0);
1102
+ const totalBuilt = nodes.reduce((a, n) => a + (n ? Math.round(n.getBoundingClientRect().height) : 0), 0);
1103
+
1104
+ document.getElementById('summary').innerHTML = `
1105
+ <div class="stat"><b class="${fail?'bad':'ok'}">${pass}/${SECTIONS.length}</b>sections match (height + content x)</div>
1106
+ <div class="stat"><b class="${totalBuilt===totalFigma?'ok':'bad'}">${totalBuilt} / ${totalFigma}</b>total px built / Figma</div>
1107
+ <div class="stat"><b class="${overflow?'bad':'ok'}">${overflow?'yes':'no'}</b>horizontal overflow</div>
1108
+ <div class="stat"><b>${DESIGN_W}px</b>design width</div>
1109
+ <div class="stat"><b class="${UNVERIFIED.length?'bad':'ok'}">${UNVERIFIED.length}</b>sections with no reference</div>
1110
+ <div class="stat"><b class="${blankImgs.length?'bad':'ok'}">${blankImgs.length}</b>images that render as a blank block</div>`;
1111
+ if (blankImgs.length) {
1112
+ document.querySelector('header').insertAdjacentHTML('beforeend',
1113
+ `<div class="sub bad" style="margin-top:8px">Render as a solid/blank block (broken logo or
1114
+ failed vector extraction — LOOK at them, use the raster export if a wordmark):
1115
+ ${blankImgs.map(b => `<code>${b.src} (${b.wh})</code>`).join(', ')}</div>`);
1116
+ }
1117
+ if (UNVERIFIED.length) {
1118
+ document.querySelector('header').insertAdjacentHTML('beforeend',
1119
+ `<div class="sub" style="margin-top:8px" class="bad">Not verified by this report (no reference slice):
1120
+ <code>${UNVERIFIED.join('</code>, <code>')}</code></div>`);
1121
+ }
1122
+
1123
+ const t = document.createElement('table');
1124
+ t.innerHTML = `<tr><th>section</th><th>height figma / built</th>
1125
+ <th>content x figma / built</th><th>verdict</th></tr>${rows}`;
1126
+ document.querySelector('header').appendChild(t);
1127
+
1128
+ const totalTexts = SECTIONS.reduce((a,s)=>a+(s.texts?s.texts.length:0),0);
1129
+ const t2 = document.createElement('table');
1130
+ t2.id = 'textAudit';
1131
+ const notFoundN = totalTexts - tally.found;
1132
+ t2.innerHTML = `<tr><th colspan="3">text audit — of ${totalTexts} design text nodes:
1133
+ <b>${tally.found}</b> present in the DOM · <b>${tally.position}</b> at the right position (±4px) ·
1134
+ <b>${tally.size}</b> right size · <b>${tally.weight}</b> right weight · <b>${tally.colour}</b> right colour.
1135
+ A perfect node is present, positioned, sized, weighted and coloured — ${totalTexts - textIssues.length}
1136
+ clear all five.</th></tr>` +
1137
+ (textIssues.length
1138
+ ? `<tr><th>section</th><th>text</th><th>difference</th></tr>` +
1139
+ textIssues.map(i=>`<tr><td>${i.s}</td><td><code>${i.t.replace(/</g,'&lt;')}</code></td>
1140
+ <td class="bad">${i.issue}</td></tr>`).join('')
1141
+ : `<tr><td class="ok" colspan="3">no differences</td></tr>`);
1142
+ document.querySelector('header').appendChild(t2);
1143
+
1144
+ document.getElementById('summary').insertAdjacentHTML('beforeend',
1145
+ `<div class="stat"><b class="${notFoundN?'bad':'ok'}">${tally.found}/${totalTexts}</b>copy present in the DOM</div>
1146
+ <div class="stat"><b class="${totalTexts - textIssues.length < totalTexts?'bad':'ok'}">${totalTexts - textIssues.length}/${totalTexts}</b>text nodes fully match</div>`);
1147
+
1148
+ if (selProblems.length) {
1149
+ document.querySelector('header').insertAdjacentHTML('beforeend',
1150
+ `<div class="sub bad" style="margin-top:8px">Section mapping problems: ${selProblems.join(' · ')}</div>`);
1151
+ }
1152
+ const totalBoxes = SECTIONS.reduce((a,s)=>a+(s.boxes?s.boxes.length:0),0);
1153
+ const checked = totalBoxes - boxUnmatched.length;
1154
+ document.getElementById('summary').insertAdjacentHTML('beforeend',
1155
+ `<div class="stat"><b class="${boxIssues.length?'bad':'ok'}">${checked - boxIssues.length}/${checked}</b>boxes match fill + radius</div>`);
1156
+ const t4 = document.createElement('table');
1157
+ t4.id = 'boxAudit';
1158
+ t4.innerHTML = `<tr><th colspan="2">box audit — fill colour and corner radius of non-text elements
1159
+ (${checked} of ${totalBoxes} design boxes had a 1:1 DOM element; the rest are structural
1160
+ differences, not necessarily defects — decide, do not ignore)</th></tr>` +
1161
+ (boxIssues.length
1162
+ ? `<tr><th>section</th><th>difference</th></tr>` +
1163
+ boxIssues.slice(0,40).map(i=>`<tr><td>${i.s}</td><td class="bad">${i.m}</td></tr>`).join('') +
1164
+ (boxIssues.length>40?`<tr><td colspan="2">… ${boxIssues.length-40} more</td></tr>`:'')
1165
+ : `<tr><td class="ok" colspan="2">no fill or radius differences among matched boxes</td></tr>`);
1166
+ document.querySelector('header').appendChild(t4);
1167
+
1168
+ document.getElementById('summary').insertAdjacentHTML('beforeend',
1169
+ `<div class="stat"><b class="${motionIssues.length?'bad':'ok'}">${motionChecked - motionIssues.length}/${motionChecked}</b>hover timings match</div>
1170
+ <div class="stat"><b class="${ASSETS_MAP ? (identityIssues.length?'bad':'ok') : 'bad'}">${ASSETS_MAP ? (identityChecked - identityIssues.length) + '/' + identityChecked : 'n/a'}</b>images are the RIGHT asset</div>`);
1171
+ if (!ASSETS_MAP) {
1172
+ document.querySelector('header').insertAdjacentHTML('beforeend',
1173
+ `<div class="sub bad" style="margin-top:8px">No --assets-map given: this report checks that
1174
+ <em>a</em> photo is present, never that it is the <em>right</em> photo.</div>`);
1175
+ }
1176
+ const otherBps = BREAKPOINTS.filter(w => w !== DESIGN_W);
1177
+ if (otherBps.length) {
1178
+ document.querySelector('header').insertAdjacentHTML('beforeend',
1179
+ `<div class="sub bad" style="margin-top:8px">Confirmed breakpoints NOT covered by this report:
1180
+ <code>${otherBps.join('px</code>, <code>')}px</code>. Each is a separate design: build it and
1181
+ run this report against it. A responsive layout you inferred is not a substitute.</div>`);
1182
+ }
1183
+
1184
+ [[motionIssues, 'motionAudit', 'motion audit — hover transition durations vs the design'],
1185
+ [identityIssues, 'identityAudit', 'image identity — is the right photo wired to this node?']]
1186
+ .forEach(([issues, id, title]) => {
1187
+ const t = document.createElement('table');
1188
+ t.id = id;
1189
+ t.innerHTML = `<tr><th colspan="2">${title}</th></tr>` +
1190
+ (issues.length
1191
+ ? `<tr><th>section</th><th>difference</th></tr>` +
1192
+ issues.slice(0,30).map(i=>`<tr><td>${i.s}</td><td class="bad">${i.m}</td></tr>`).join('') +
1193
+ (issues.length>30?`<tr><td colspan="2">… ${issues.length-30} more</td></tr>`:'')
1194
+ : `<tr><td class="ok" colspan="2">no differences</td></tr>`);
1195
+ document.querySelector('header').appendChild(t);
1196
+ });
1197
+
1198
+ const totalImgs = gfx.image.real + gfx.image.placeholder + gfx.image.missing;
1199
+ // The identity checks fetch files; nothing may be reported until they all land.
1200
+ Promise.all([comparatorOK, ...identityChecks]).catch(() => {}).then(() => {
1201
+ const allGfx = gfxIssues.concat(identityIssues, extraGraphics);
1202
+ const totalIcons = gfx.icon.asset + gfx.icon.drawn + gfx.icon.missing + gfx.icon.offset;
1203
+ const right = gfx.icon.asset - gfx.icon.wrong;
1204
+ const iconBad = gfx.icon.drawn || gfx.icon.missing || gfx.icon.offset || gfx.icon.wrong || gfx.icon.extra;
1205
+ document.getElementById('summary').insertAdjacentHTML('beforeend',
1206
+ `<div class="stat"><b class="${iconBad?'bad':'ok'}">${right}/${totalIcons}</b>icons: right asset, right place</div>
1207
+ <div class="stat"><b class="${gfx.icon.extra?'bad':'ok'}">${gfx.icon.extra}</b>graphics the design has no node for</div>
1208
+ <div class="stat"><b class="${gfx.image.placeholder||gfx.image.missing?'bad':'ok'}">${gfx.image.real}/${totalImgs}</b>images are real photos</div>`);
1209
+
1210
+ const t3 = document.createElement('table');
1211
+ t3.id = 'gfxAudit';
1212
+ t3.innerHTML = `<tr><th colspan="3">graphics audit — icons: ${right} correct,
1213
+ ${gfx.icon.wrong} wrong icon on the node, ${gfx.icon.offset} placed wrong,
1214
+ ${gfx.icon.drawn} hand-drawn, ${gfx.icon.missing} absent, ${gfx.icon.extra} not in the design ·
1215
+ images: ${gfx.image.real} real, ${gfx.image.placeholder} placeholder, ${gfx.image.missing} missing</th></tr>` +
1216
+ (gfx.icon.unsigned ? `<tr><td class="bad" colspan="3">${gfx.icon.unsigned} icon(s) on the page carry
1217
+ no <code>data-icon-shape</code>: they were not produced by figma_icons.py, so nothing can say
1218
+ whether they are the right drawing. Re-export them.</td></tr>` : '') +
1219
+ (ICONS_DIR ? '' : `<tr><td class="bad" colspan="3">No icons.json manifest found: this report
1220
+ checks that <em>an</em> icon is present, never that it is the <em>right</em> icon.
1221
+ Run figma_icons.py first, or pass --icons-dir.</td></tr>`) +
1222
+ (allGfx.length
1223
+ ? `<tr><th>section</th><th>kind</th><th>problem</th></tr>` +
1224
+ allGfx.slice(0, 60).map(i=>`<tr><td>${i.s}</td><td>${i.k}</td><td class="bad">${i.m}</td></tr>`).join('') +
1225
+ (allGfx.length > 60 ? `<tr><td colspan="3">… ${allGfx.length - 60} more</td></tr>` : '')
1226
+ : `<tr><td class="ok" colspan="3">every icon is the design's own asset, in the design's own place,
1227
+ and nothing is drawn that the design does not have</td></tr>`);
1228
+ document.querySelector('header').appendChild(t3);
1229
+ if (SUBSTITUTED.size) {
1230
+ document.querySelector('header').insertAdjacentHTML('beforeend',
1231
+ `<div class="sub" style="margin-top:8px">Font substitutions in effect (weight not compared):
1232
+ <code>${[...SUBSTITUTED].join('</code>, <code>')}</code></div>`);
1233
+ }
1234
+ });
1235
+ } catch (err) { reportCrash(err); }
1236
+ });
1237
+ </script>
1238
+ """
1239
+
1240
+
1241
+ MANIFEST = {}
1242
+
1243
+
1244
+ def load_manifest(d):
1245
+ f = pathlib.Path(d) / "icons.json"
1246
+ if f.exists():
1247
+ MANIFEST.update(json.loads(f.read_text()))
1248
+ return bool(MANIFEST)
1249
+
1250
+
1251
+ def selfcheck(path):
1252
+ """The report is a program. If it does not parse, it silently shows 'measuring…' forever
1253
+ and a reader takes that for 'still working'. Refuse to hand over a broken one."""
1254
+ import re, shutil, subprocess, tempfile
1255
+ node = shutil.which("node")
1256
+ if not node:
1257
+ return
1258
+ m = re.findall(r"<script>(.*?)</script>", pathlib.Path(path).read_text(), re.S)
1259
+ if not m:
1260
+ return
1261
+ with tempfile.NamedTemporaryFile("w", suffix=".js", delete=False) as f:
1262
+ f.write(m[-1])
1263
+ tmp = f.name
1264
+ r = subprocess.run([node, "--check", tmp], capture_output=True, text=True)
1265
+ if r.returncode:
1266
+ sys.exit(f"BUG in figma_report.py: the report it just wrote does not parse.\n{r.stderr}")
1267
+
1268
+
1269
+ def main():
1270
+ ap = argparse.ArgumentParser()
1271
+ ap.add_argument("--page", default="index.html")
1272
+ ap.add_argument("--nodes", default="figma/nodes")
1273
+ ap.add_argument("--renders", default="figma/renders")
1274
+ ap.add_argument("--selectors", default=None)
1275
+ ap.add_argument("--only", default=None,
1276
+ help="verify a single section while you are building it (§6.5.5)")
1277
+ ap.add_argument("--breakpoints", default=None,
1278
+ help="comma-separated widths the user confirmed are real breakpoints; "
1279
+ "any width other than this report's is flagged as unbuilt")
1280
+ ap.add_argument("--assets-map", default=None,
1281
+ help="JSON {imageRef: filename} so image IDENTITY can be checked, "
1282
+ "not just 'a photo is present'")
1283
+ ap.add_argument("--out", default="fidelity-report.html")
1284
+ ap.add_argument("--icons-dir", default="design/exports/icons",
1285
+ help="output of figma_icons.py; enables the icon-identity check")
1286
+ a = ap.parse_args()
1287
+ have_manifest = load_manifest(a.icons_dir)
1288
+
1289
+ sections = load_sections(a.nodes, a.renders)
1290
+ known = {pathlib.Path(f).stem for f in glob.glob(os.path.join(a.nodes, "*.json"))}
1291
+ unverified = sorted(known - {s["name"] for s in sections})
1292
+ if a.only:
1293
+ sections = [s for s in sections if s["name"] == a.only]
1294
+ if not sections:
1295
+ raise SystemExit(f"no section named {a.only!r}")
1296
+ design_w = max(s["w"] for s in sections)
1297
+ selectors = json.load(open(a.selectors)) if a.selectors else None
1298
+
1299
+ bps = [int(x) for x in a.breakpoints.split(",")] if a.breakpoints else []
1300
+ amap = json.load(open(a.assets_map)) if a.assets_map else None
1301
+ html = (HTML
1302
+ .replace("__BREAKPOINTS__", json.dumps(bps))
1303
+ .replace("__ASSETS_MAP__", json.dumps(amap))
1304
+ .replace("__UNVERIFIED__", json.dumps(unverified))
1305
+ .replace("__SECTIONS__", json.dumps(sections))
1306
+ .replace("__PAGE__", a.page)
1307
+ .replace("__DESIGN_W__", str(design_w))
1308
+ .replace("__SELECTORS__", json.dumps(selectors))
1309
+ .replace("__ICONS_DIR__", json.dumps(a.icons_dir if have_manifest else None))
1310
+ .replace("__MANIFEST__", json.dumps(MANIFEST)))
1311
+ pathlib.Path(a.out).write_text(html)
1312
+ selfcheck(a.out)
1313
+
1314
+ print(f"wrote {a.out} ({len(sections)} section(s), design width {design_w}px)")
1315
+ print("Serve the project and open it — the numbers are computed live, in the browser.")
1316
+ print("Hand the user this file. Do not claim a match it does not show.")
1317
+
1318
+
1319
+ if __name__ == "__main__":
1320
+ main()