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.
- package/LICENSE +21 -0
- package/README.md +217 -0
- package/SKILL.md +1855 -0
- package/bin/install.js +42 -0
- package/package.json +23 -0
- package/references/accessibility-checklist.md +68 -0
- package/references/animation-guidelines.md +113 -0
- package/references/visual-review-checklist.md +121 -0
- package/scripts/figma_discover.py +218 -0
- package/scripts/figma_fonts.py +90 -0
- package/scripts/figma_icons.py +391 -0
- package/scripts/figma_lint.py +246 -0
- package/scripts/figma_pull.py +153 -0
- package/scripts/figma_report.py +1320 -0
- package/scripts/figma_spec.py +93 -0
|
@@ -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,'<')}</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()
|