mosaic-headless 1.2.1 → 1.3.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.
@@ -28,6 +28,19 @@ Statuses
28
28
  APPLIED the property changed the delivered HTML in the way it claims to
29
29
  NO_EFFECT correctly shaped value, committed, nothing changed in the markup
30
30
  SKIPPED no value could be derived from the validator chain; NOT a pass
31
+ INSTRUMENT this sweep USES the property to run itself, so it cannot also be the
32
+ subject - but another pass covers it, named per row. NOT a pass here
33
+
34
+ `INSTRUMENT` exists because the alternative is a lie in either direction. This sweep
35
+ finds each probe node by its `attrID` and reads its `style` back out of the compiled
36
+ CSS; asking it to probe those two is asking a ruler to measure itself, and it
37
+ returned `SKIPPED` for both. But `SKIPPED` reads as "nobody checked", and `attrID`
38
+ and `style` are in fact the two most heavily asserted properties in the skill: every
39
+ row of `style-verification.csv` is a `style` assertion and every row of
40
+ `rwd-verification.csv` is a `style` assertion located by `attrID`. Relabelling them
41
+ as passes would be the blind-spot-scored-as-success failure this file was written to
42
+ avoid; leaving them as SKIPPED understates the evidence by nearly seven hundred rows.
43
+ So they get their own status, and it carries the count and the file that holds it.
31
44
  """
32
45
  import argparse
33
46
  import csv
@@ -163,8 +176,32 @@ def main():
163
176
  for r in types.values():
164
177
  by_class.setdefault(r["data_class"], []).append(r["type"])
165
178
 
179
+ # The count is read out of the covering CSV at run time rather than typed in, so
180
+ # the claim cannot drift away from the evidence it points at.
181
+ def covering(files):
182
+ parts = []
183
+ for name in files:
184
+ path = os.path.join(here, "..", "data", name + ".csv")
185
+ if not os.path.exists(path):
186
+ continue
187
+ with open(path, encoding="utf-8", newline="") as fh:
188
+ n = sum(1 for _ in csv.DictReader(fh))
189
+ parts.append("%s (%d rows)" % (name + ".csv", n))
190
+ return "; ".join(parts)
191
+
192
+ INSTRUMENT = {
193
+ "attrID": ["style-verification", "rwd-verification"],
194
+ "style": ["style-verification", "rwd-verification"],
195
+ }
196
+
166
197
  plan = []
167
198
  for row in props:
199
+ if row["property"] in INSTRUMENT:
200
+ plan.append((None, row["property"], None,
201
+ ("INSTRUMENT", "this sweep locates and reads nodes THROUGH "
202
+ "it; asserted instead by " +
203
+ covering(INSTRUMENT[row["property"]]))))
204
+ continue
168
205
  value = probe_value(row)
169
206
  if value is None:
170
207
  plan.append((None, row["property"], None, "SKIPPED"))
@@ -245,7 +282,9 @@ def main():
245
282
  status = "EDITOR_ONLY"
246
283
  rows.append([prop, host, json.dumps(value, ensure_ascii=False), status, evidence])
247
284
  for host, prop, value, pre in plan:
248
- if pre in ("SKIPPED", "NO_HOST"):
285
+ if isinstance(pre, tuple):
286
+ rows.append([prop, "", "", pre[0], pre[1]])
287
+ elif pre in ("SKIPPED", "NO_HOST"):
249
288
  rows.append([prop, "", "", pre, ""])
250
289
 
251
290
  counts = {}
@@ -253,7 +292,7 @@ def main():
253
292
  counts[r[3]] = counts.get(r[3], 0) + 1
254
293
  print()
255
294
  for k in ("APPLIED", "NO_EFFECT", "EDITOR_ONLY", "NO_ELEMENT", "NO_HOST",
256
- "SKIPPED"):
295
+ "INSTRUMENT", "SKIPPED"):
257
296
  if counts.get(k):
258
297
  print(" %-12s %d" % (k, counts[k]))
259
298
  applied = sorted({r[0] for r in rows if r[3] == "APPLIED"})
@@ -0,0 +1,589 @@
1
+ #!/usr/bin/env python3
2
+ """Assert the page a BROWSER computes, not the one the stylesheet promises.
3
+
4
+ pip install playwright && playwright install chromium
5
+ python tools/verify_browser.py --config c.json --site sites/moksa.json
6
+ python tools/verify_browser.py --config c.json --site sites/moksa.json \
7
+ --csv browser.csv --shots shots/
8
+
9
+ Every other check in this skill reads text. `verify_rwd.py` proves a declaration
10
+ reached the served stylesheet; `sweep_style_properties.py` proves the compiler emits
11
+ it at all. Neither can see what the browser then does with it, and there are three
12
+ whole classes of defect that live in exactly that gap:
13
+
14
+ the rule is in the stylesheet and LOSES another selector is more specific
15
+ the rule applies and means something else `-0.035em` is -2.17px at 62px
16
+ the rule applies to a font that is not there a CJK string in a Latin-only face
17
+ silently renders in the fallback,
18
+ still carrying the Latin tracking
19
+
20
+ The third one is not hypothetical. It is how this page's headline came to be set with
21
+ `letter-spacing: -2.17px` on Han characters - a stylesheet check called it verified,
22
+ because it WAS verified: the declaration was present, correct, and wrong.
23
+
24
+ So this tool opens the public URL in Chromium at each breakpoint and asks the element
25
+ itself. Two passes:
26
+
27
+ COMPUTED every declared style property vs `getComputedStyle` on the node it targets.
28
+ A declared value is only compared where a normalisation exists that cannot
29
+ lie - lengths, colours, keywords, unitless ratios, `em` resolved against the
30
+ element's own font-size. Everything else is `not-comparable`, which is a
31
+ blind spot and is never counted as a pass.
32
+
33
+ AUDIT what only a browser knows, checked whether or not it was declared: font
34
+ fallback, tracking against script, text contrast, horizontal overflow,
35
+ clipped text, and line measure.
36
+
37
+ Exit status is non-zero if any declaration is OVERRIDDEN or any audit finding is
38
+ rated `error`.
39
+ """
40
+ from __future__ import annotations
41
+
42
+ import argparse
43
+ import csv
44
+ import json
45
+ import os
46
+ import re
47
+ import sys
48
+
49
+ # The breakpoint keys, widest first, with the viewport each one owns. `_t` is
50
+ # "<=1079px" and `_m` is "<=767px" - see references/responsive.md. The widths chosen
51
+ # sit comfortably inside each band rather than on its edge, so a rounding difference
52
+ # in the media query cannot decide the result.
53
+ VIEWPORTS = [("_", 1440, 900), ("_t", 900, 1000), ("_m", 390, 844)]
54
+
55
+ ALIASES = {
56
+ "gridCols": "grid-template-columns",
57
+ "radius": "border-radius",
58
+ "move": "transform",
59
+ "shadow": "box-shadow",
60
+ "transitionAll": "transition",
61
+ "objectFitStyle": "object-fit",
62
+ "backgroundStyle": "background-image",
63
+ }
64
+
65
+ # Properties whose computed form cannot be compared to the declared one without
66
+ # guessing. Listed rather than silently dropped.
67
+ INCOMPARABLE = {
68
+ "transitionAll", # the browser rewrites and reorders it
69
+ "move", "shadow", # composite; the computed matrix is not the input
70
+ "backgroundStyle", # measured inert anyway - see SKILL.md
71
+ }
72
+
73
+
74
+ def expand_custom(raw):
75
+ """`customStyles` into its individual declarations.
76
+
77
+ It is a raw CSS string, and treating it as one opaque value made it the single
78
+ largest blind spot in this tool - three hundred not-comparable rows on the
79
+ example page, more than every other unchecked property combined. It is also
80
+ where this skill sends you whenever a style property turns out to be inert, so
81
+ leaving it unchecked means the escape hatch is the least verified part of the
82
+ page. Split on `;`, and each half of each pair is an ordinary declaration that
83
+ the browser will happily report back."""
84
+ out = []
85
+ for chunk in (raw or "").split(";"):
86
+ if ":" not in chunk:
87
+ continue
88
+ prop, _, value = chunk.partition(":")
89
+ prop, value = prop.strip(), value.strip()
90
+ # A nested block (`@media`, or a selector with a brace) is not a flat
91
+ # declaration list and is not unpicked here.
92
+ if not prop or "{" in chunk or "}" in chunk:
93
+ continue
94
+ out.append((prop, value))
95
+ return out
96
+
97
+
98
+ def kebab(name):
99
+ return re.sub(r"(?<!^)(?=[A-Z])", "-", name).lower()
100
+
101
+
102
+ def css_name(key):
103
+ return ALIASES.get(key, kebab(key))
104
+
105
+
106
+ # ── the declared side ─────────────────────────────────────────────────────────
107
+
108
+ def walk(node, out):
109
+ """(attrID, breakpoint, key, value) for every base-state declaration.
110
+
111
+ Only the base state `&`. A `hover` or `focus` declaration is not on the element
112
+ until a pointer is on it, and reporting it as missing would be the tool lying."""
113
+ if isinstance(node, dict):
114
+ data = node.get("data")
115
+ attr = data.get("attrID") if isinstance(data, dict) else None
116
+ style = node.get("style")
117
+ state = style.get("&") if isinstance(style, dict) else None
118
+ if attr and isinstance(state, dict):
119
+ for bp, decls in state.items():
120
+ if isinstance(decls, dict) and bp in {"_", "_t", "_m"}:
121
+ for key, value in decls.items():
122
+ out.append((attr, bp, key, value))
123
+ for v in node.values():
124
+ walk(v, out)
125
+ elif isinstance(node, list):
126
+ for v in node:
127
+ walk(v, out)
128
+ return out
129
+
130
+
131
+ def effective(decls, bp):
132
+ """What is in force at `bp`, given that a narrower breakpoint INHERITS from the
133
+ wider one and overrides it. This is the whole reason a per-breakpoint reading is
134
+ not just a filter: at `_m` an element is wearing its `_` values except where `_t`
135
+ or `_m` replaced them, and asserting only the `_m` keys would check a third of
136
+ the page."""
137
+ order = ["_"] + (["_t"] if bp in ("_t", "_m") else []) + (["_m"] if bp == "_m"
138
+ else [])
139
+ out: dict[str, object] = {}
140
+ for level in order:
141
+ out.update(decls.get(level, {}))
142
+ return out
143
+
144
+
145
+ def declared_map(spec):
146
+ per: dict[str, dict[str, dict]] = {}
147
+ for attr, bp, key, value in walk(spec, []):
148
+ per.setdefault(attr, {}).setdefault(bp, {})[key] = value
149
+ return per
150
+
151
+
152
+ # ── comparison ────────────────────────────────────────────────────────────────
153
+
154
+ NUM = re.compile(r"-?\d*\.?\d+")
155
+
156
+
157
+ def numbers(text):
158
+ return [float(x) for x in NUM.findall(text or "")]
159
+
160
+
161
+ def tidy(text):
162
+ """Whitespace and decimal shorthand are not differences.
163
+
164
+ `rgba(22,24,28,.16)` and `rgba(22, 24, 28, 0.16)` are the same declaration; the
165
+ browser simply prints it the long way. Without this the customStyles pass would
166
+ report every hairline on the page as overridden."""
167
+ t = re.sub(r"\s*,\s*", ",", (text or "").strip().lower())
168
+ t = re.sub(r"\s+", " ", t)
169
+ return re.sub(r"(?<![\d.])\.(\d)", r"0.", t)
170
+
171
+
172
+ # The comparison knows properties by their Mosaic key, but customStyles hands it raw
173
+ # CSS names. One spelling has to be canonical, so the CSS name maps back.
174
+ FROM_CSS = {"font-family": "fontFamily", "line-height": "lineHeight",
175
+ "grid-template-columns": "gridCols", "transition": "transitionAll",
176
+ "border-radius": "radius", "transform": "move", "box-shadow": "shadow"}
177
+
178
+
179
+ def compare(key, declared, computed, font_px, root_px):
180
+ """(status, expected_note). Only ever returns `ok` when the comparison is sound.
181
+
182
+ Anything this function is not sure about must come back `not-comparable`. A
183
+ verifier that guesses in order to raise its own pass rate is worse than no
184
+ verifier, because it is trusted."""
185
+ key = FROM_CSS.get(key, key)
186
+ if key in INCOMPARABLE:
187
+ return "not-comparable", "composite or raw value"
188
+ if isinstance(declared, dict):
189
+ # {"token": "--x"} was resolved before this call; anything else structured
190
+ # (border groups, shadow objects) has no single computed counterpart.
191
+ return "not-comparable", "structured value"
192
+ if not isinstance(declared, str):
193
+ declared = str(declared)
194
+ d, c = declared.strip(), (computed or "").strip()
195
+ if not c:
196
+ return "not-comparable", "no computed value"
197
+
198
+ # `auto`, and the CSS-wide keywords, name a RULE for arriving at a value rather
199
+ # than a value. `getComputedStyle` reports what the rule produced - `margin-left:
200
+ # auto` on a centred 1240px container in a 1440px viewport computes to 60px, and
201
+ # to 0px once the container fills its parent. Comparing the keyword to the number
202
+ # calls a correctly working page overridden, forty-eight times, which is the
203
+ # tool guessing in exactly the way its own docstring forbids.
204
+ # The intrinsic sizing keywords behave the same way: `width: max-content` is an
205
+ # instruction to measure the content, and what comes back is the measurement.
206
+ if d.lower() in ("auto", "inherit", "initial", "unset", "revert", "normal",
207
+ "max-content", "min-content", "fit-content"):
208
+ return "not-comparable", "%s resolves to a used value (%s)" % (d.lower(), c)
209
+
210
+ # A grid template computes to resolved pixel tracks, so the declared string can
211
+ # never match. The number of tracks can, and it is what actually fails: a grid
212
+ # that did not apply has one track, not four. Comparing structure rather than
213
+ # text is sound here in a way that comparing `1.35fr .65fr` to `633px 305px`
214
+ # never could be.
215
+ if key == "gridCols":
216
+ want = len([t for t in re.split(r"\s+", d) if t])
217
+ rep = re.sub(r"repeat\(\s*(\d+)", lambda m: " ".join(["x"] * int(m.group(1))), d)
218
+ if "repeat(" in d:
219
+ want = len([t for t in re.split(r"\s+", rep.replace(")", " ")) if t
220
+ and not t.endswith(",")]) - 0
221
+ inner = re.match(r"repeat\(\s*(\d+)", d)
222
+ want = int(inner.group(1)) if inner else want
223
+ got = len([t for t in re.split(r"\s+", c) if t])
224
+ return ("ok" if want == got else "OVERRIDDEN"), "%d tracks, wanted %d" % (got,
225
+ want)
226
+
227
+ # colours - compare the channel numbers, so rgb(22,24,28) matches rgb(22, 24, 28)
228
+ if d.startswith(("rgb", "#")) or c.startswith("rgb"):
229
+ dn, cn = numbers(d), numbers(c)
230
+ if d.startswith("#"):
231
+ h = d.lstrip("#")
232
+ if len(h) == 3:
233
+ h = "".join(ch * 2 for ch in h)
234
+ if len(h) == 6:
235
+ dn = [int(h[i:i + 2], 16) for i in (0, 2, 4)]
236
+ if len(dn) >= 3 and len(cn) >= 3:
237
+ return ("ok" if dn[:3] == cn[:3] else "OVERRIDDEN"), c
238
+ return "not-comparable", "colour shape"
239
+
240
+ # font stacks - the browser echoes the whole stack; only the first face is a
241
+ # decision this spec made. Whether that face was actually USED is a different
242
+ # question and the audit answers it.
243
+ if key == "fontFamily":
244
+ first = lambda s: re.split(r"\s*,\s*", s)[0].strip().strip("'\"").lower()
245
+ return ("ok" if first(d) == first(c) else "OVERRIDDEN"), c
246
+
247
+ dn, cn = numbers(d), numbers(c)
248
+ if len(dn) == 1 and len(cn) == 1:
249
+ want = dn[0]
250
+ if d.endswith("px"):
251
+ pass
252
+ elif d.endswith("em"):
253
+ want = dn[0] * (font_px or 16.0)
254
+ elif d.endswith("rem"):
255
+ want = dn[0] * (root_px or 16.0)
256
+ elif d.endswith("%") or d.endswith(("vw", "vh")):
257
+ return "not-comparable", "viewport- or parent-relative"
258
+ elif re.fullmatch(r"-?\d*\.?\d+", d):
259
+ # unitless: a ratio (line-height) resolves to px, a weight does not
260
+ want = dn[0] * (font_px or 16.0) if key == "lineHeight" else dn[0]
261
+ else:
262
+ return "not-comparable", "unit %s" % d
263
+ return ("ok" if abs(want - cn[0]) <= 0.6 else "OVERRIDDEN"), c
264
+
265
+ return ("ok" if tidy(d) == tidy(c) else "OVERRIDDEN"), c
266
+
267
+
268
+ # ── the browser side ──────────────────────────────────────────────────────────
269
+
270
+ # One script, run once per viewport. Returns the computed values for every attrID we
271
+ # asked about plus the audit findings, so a page costs one round trip rather than one
272
+ # per property.
273
+ PROBE = r"""
274
+ (ids) => {
275
+ const out = {nodes: {}, audit: [], doc: {}};
276
+ const CJK = /[㐀-䶿一-鿿぀-ヿ가-힯豈-﫿]/;
277
+ const px = v => parseFloat(v) || 0;
278
+
279
+ const root = document.documentElement;
280
+ out.doc.rootFontSize = getComputedStyle(root).fontSize;
281
+ out.doc.scrollWidth = root.scrollWidth;
282
+ out.doc.clientWidth = root.clientWidth;
283
+
284
+ // Horizontal overflow, attributed. `scrollWidth > clientWidth` on its own tells
285
+ // you the page scrolls sideways but not what is doing it, which is the only part
286
+ // anyone can act on. An element wider than the viewport is only a cause if no
287
+ // ancestor is clipping it - a marquee inside `overflow:hidden` is intentional.
288
+ if (root.scrollWidth > root.clientWidth + 1) {
289
+ const guilty = [];
290
+ for (const el of document.body.querySelectorAll('*')) {
291
+ const r = el.getBoundingClientRect();
292
+ if (r.right <= root.clientWidth + 1 && r.left >= -1) continue;
293
+ let clipped = false;
294
+ for (let p = el.parentElement; p; p = p.parentElement) {
295
+ const o = getComputedStyle(p);
296
+ if (o.overflowX !== 'visible') { clipped = true; break; }
297
+ }
298
+ if (!clipped) guilty.push({id: el.id || '', tag: el.tagName.toLowerCase(),
299
+ cls: (el.className || '').toString().slice(0, 40),
300
+ right: Math.round(r.right)});
301
+ }
302
+ if (guilty.length) out.audit.push({
303
+ check: 'H_OVERFLOW', level: 'error',
304
+ detail: 'page scrolls sideways: ' + root.scrollWidth + ' > ' + root.clientWidth,
305
+ nodes: guilty.slice(0, 10)});
306
+ }
307
+
308
+ // Effective background behind a text node, for contrast. Walks up until something
309
+ // is actually painted; a transparent parent is not a background.
310
+ const bgOf = el => {
311
+ for (let p = el; p; p = p.parentElement) {
312
+ const b = getComputedStyle(p).backgroundColor;
313
+ const n = (b.match(/[\d.]+/g) || []).map(Number);
314
+ if (n.length >= 3 && (n.length < 4 || n[3] > 0.55)) return n.slice(0, 3);
315
+ }
316
+ return [255, 255, 255];
317
+ };
318
+ const lum = c => {
319
+ const s = c.map(v => { v /= 255; return v <= .03928 ? v / 12.92
320
+ : Math.pow((v + .055) / 1.055, 2.4); });
321
+ return .2126 * s[0] + .7152 * s[1] + .0722 * s[2];
322
+ };
323
+ const ratio = (a, b) => {
324
+ const [x, y] = [lum(a), lum(b)].sort((m, n) => n - m);
325
+ return (x + .05) / (y + .05);
326
+ };
327
+
328
+ for (const el of document.body.querySelectorAll('*')) {
329
+ const own = Array.from(el.childNodes)
330
+ .filter(n => n.nodeType === 3).map(n => n.textContent).join('').trim();
331
+ if (!own) continue;
332
+ const cs = getComputedStyle(el);
333
+ if (cs.visibility === 'hidden' || cs.display === 'none' || px(cs.opacity) === 0)
334
+ continue;
335
+ const size = px(cs.fontSize);
336
+ const id = el.id || (el.tagName.toLowerCase() + '.' +
337
+ (el.className || '').toString().split(' ')[0]);
338
+ const hasCJK = CJK.test(own);
339
+
340
+ // Negative tracking on Han/Kana/Hangul. Latin grotesques are drawn to be
341
+ // tightened; CJK glyphs sit on a full em body and are already as close as they
342
+ // are meant to be, so a negative value there is always a defect - and it is
343
+ // usually inherited from a Latin display face the text cannot even use.
344
+ const track = px(cs.letterSpacing);
345
+ if (hasCJK && track < -0.15) out.audit.push({
346
+ check: 'CJK_NEGATIVE_TRACKING', level: 'error', node: id,
347
+ detail: cs.letterSpacing + ' at ' + cs.fontSize + ' on CJK text',
348
+ sample: own.slice(0, 24)});
349
+
350
+ // Did the first declared face actually render the text? `document.fonts.check`
351
+ // answers for the family, which is what catches a CJK string assigned a
352
+ // Latin-only face: the declaration is honoured, the glyphs come from the next
353
+ // family in the stack, and nothing anywhere reports it.
354
+ const first = cs.fontFamily.split(',')[0].trim().replace(/^['"]|['"]$/g, '');
355
+ if (first && !/^(ui-|system-|-apple|sans-serif|serif|monospace)/.test(first)) {
356
+ const spec = cs.fontStyle + ' ' + cs.fontWeight + ' ' + cs.fontSize +
357
+ ' "' + first + '"';
358
+ let ok = true;
359
+ try { ok = document.fonts.check(spec, own.slice(0, 40)); } catch (e) {}
360
+ if (!ok) out.audit.push({
361
+ check: 'FONT_FALLBACK', level: hasCJK ? 'error' : 'warn', node: id,
362
+ detail: '"' + first + '" cannot render this text; it falls back',
363
+ sample: own.slice(0, 24)});
364
+ }
365
+
366
+ const fg = (cs.color.match(/[\d.]+/g) || []).map(Number).slice(0, 3);
367
+ if (fg.length === 3) {
368
+ const r = ratio(fg, bgOf(el));
369
+ const large = size >= 24 || (size >= 18.66 && px(cs.fontWeight) >= 700);
370
+ const need = large ? 3.0 : 4.5;
371
+ if (r < need) out.audit.push({
372
+ check: 'CONTRAST', level: r < need - 1 ? 'error' : 'warn', node: id,
373
+ detail: r.toFixed(2) + ':1 against its background, needs ' + need,
374
+ sample: own.slice(0, 24)});
375
+ }
376
+
377
+ // Text clipped by its own box rather than wrapped.
378
+ if (el.scrollWidth > el.clientWidth + 2 && cs.overflowX === 'hidden' &&
379
+ cs.whiteSpace !== 'nowrap')
380
+ out.audit.push({check: 'CLIPPED', level: 'warn', node: id,
381
+ detail: el.scrollWidth + 'px of text in a ' + el.clientWidth +
382
+ 'px box', sample: own.slice(0, 24)});
383
+
384
+ // Line measure, for running copy only - a heading or a label is meant to be short.
385
+ if (own.length > 90 && size > 0) {
386
+ const ch = el.clientWidth / (size * (hasCJK ? 1.0 : 0.5));
387
+ if (ch > 108) out.audit.push({
388
+ check: 'MEASURE', level: 'warn', node: id,
389
+ detail: Math.round(ch) + ' characters per line', sample: own.slice(0, 24)});
390
+ }
391
+ }
392
+
393
+ for (const id of ids) {
394
+ const el = document.getElementById(id);
395
+ if (!el) { out.nodes[id] = null; continue; }
396
+ const cs = getComputedStyle(el);
397
+ const rec = {};
398
+ for (const p of cs) rec[p] = cs.getPropertyValue(p);
399
+ rec['--font-size-px'] = cs.fontSize;
400
+ out.nodes[id] = rec;
401
+ }
402
+ return out;
403
+ }
404
+ """
405
+
406
+
407
+ def run(url, ids, shots_dir):
408
+ try:
409
+ from playwright.sync_api import sync_playwright
410
+ except ImportError:
411
+ sys.exit("verify_browser.py needs Playwright:\n"
412
+ " pip install playwright && playwright install chromium")
413
+
414
+ results = {}
415
+ with sync_playwright() as pw:
416
+ browser = pw.chromium.launch()
417
+ for bp, w, h in VIEWPORTS:
418
+ page = browser.new_page(viewport={"width": w, "height": h},
419
+ device_scale_factor=1)
420
+ # Same cache-buster as verify_rwd: a CDN serving the pre-commit page
421
+ # would make every assertion here a false negative.
422
+ sep = "&" if "?" in url else "?"
423
+ page.goto("%s%s_v=%d" % (url, sep, int(os.times()[4] * 1000) % 10 ** 9),
424
+ wait_until="networkidle", timeout=60000)
425
+ # The theme reveals itself from JavaScript (`body{opacity:0}`), and web
426
+ # fonts decide the type metrics this whole tool is about.
427
+ page.wait_for_function("document.fonts.status === 'loaded'", timeout=20000)
428
+ page.wait_for_timeout(400)
429
+ results[bp] = page.evaluate(PROBE, ids)
430
+ if shots_dir:
431
+ os.makedirs(shots_dir, exist_ok=True)
432
+ page.screenshot(path=os.path.join(shots_dir, "browser-%s.png"
433
+ % bp.strip("_") or "desktop"),
434
+ full_page=False)
435
+ page.close()
436
+ browser.close()
437
+ return results
438
+
439
+
440
+ # ── main ──────────────────────────────────────────────────────────────────────
441
+
442
+ def resolve_tokens(value, spec):
443
+ """`{"token": "--x"}` is how a spec names a design token. The browser resolves it
444
+ to whatever the token holds, so resolve the declared side the same way rather
445
+ than reporting every tokenised colour as not-comparable."""
446
+ if isinstance(value, dict) and set(value) == {"token"}:
447
+ variables = ((spec.get("theme") or {}).get("variables") or {})
448
+ entry = variables.get(value["token"])
449
+ if isinstance(entry, dict) and "value" in entry:
450
+ return entry["value"]
451
+ return value
452
+
453
+
454
+ def main():
455
+ ap = argparse.ArgumentParser()
456
+ ap.add_argument("--config", required=True)
457
+ ap.add_argument("--site", required=True)
458
+ ap.add_argument("--csv", help="the computed-value table")
459
+ ap.add_argument("--audit", help="the design-audit findings")
460
+ ap.add_argument("--shots")
461
+ ap.add_argument("--page", help="only this slug")
462
+ a = ap.parse_args()
463
+
464
+ cfg = json.load(open(a.config, encoding="utf-8"))
465
+ spec = json.load(open(a.site, encoding="utf-8"))
466
+
467
+ rows, findings, hard = [], [], 0
468
+ for page in spec["pages"]:
469
+ if a.page and page["slug"] != a.page:
470
+ continue
471
+ url = "%s/%s/" % (cfg["base"].rstrip("/"), page["slug"])
472
+ per = declared_map(page.get("tree"))
473
+ ids = sorted(per)
474
+ print("checking %s (%d nodes, %d viewports)" % (url, len(ids),
475
+ len(VIEWPORTS)))
476
+ got = run(url, ids, a.shots)
477
+
478
+ for bp, _, _ in VIEWPORTS:
479
+ res = got[bp]
480
+ root_px = float(re.sub("[^0-9.]", "", res["doc"]["rootFontSize"]) or 16)
481
+ for attr in ids:
482
+ node = res["nodes"].get(attr)
483
+ live = effective(per[attr], bp)
484
+ if node is None:
485
+ for key in live:
486
+ rows.append([url, bp, attr, key, "", "", "no-element"])
487
+ continue
488
+ font_px = float(re.sub("[^0-9.]", "",
489
+ node.get("--font-size-px", "16")) or 16)
490
+ for key, value in live.items():
491
+ value = resolve_tokens(value, spec)
492
+ if key == "customStyles" and isinstance(value, str):
493
+ for prop, raw in expand_custom(value):
494
+ computed = node.get(prop, "")
495
+ if prop.startswith("--"):
496
+ # A custom property is a value the page carries, not
497
+ # one the browser resolves; it is only readable when
498
+ # something registered it.
499
+ status, note = (("ok", computed) if computed.strip()
500
+ else ("not-comparable",
501
+ "custom property not exposed"))
502
+ else:
503
+ status, note = compare(prop, raw, computed,
504
+ font_px, root_px)
505
+ rows.append([url, bp, attr, "customStyles:" + prop, raw,
506
+ note if status != "ok" else computed,
507
+ status])
508
+ continue
509
+ prop = css_name(key)
510
+ computed = node.get(prop, "")
511
+ status, note = compare(key, value, computed, font_px, root_px)
512
+ rows.append([url, bp, attr, key,
513
+ json.dumps(value, ensure_ascii=False)
514
+ if not isinstance(value, str) else value,
515
+ note if status != "ok" else computed, status])
516
+ for f in res["audit"]:
517
+ findings.append(dict(f, url=url, breakpoint=bp))
518
+
519
+ # ── report ────────────────────────────────────────────────────────────────
520
+ counts: dict[str, int] = {}
521
+ for r in rows:
522
+ counts[r[6]] = counts.get(r[6], 0) + 1
523
+ print("\ncomputed-value pass")
524
+ for k in ("ok", "OVERRIDDEN", "no-element", "not-comparable"):
525
+ if counts.get(k):
526
+ print(" %-16s %d" % (k, counts[k]))
527
+ hard += counts.get("OVERRIDDEN", 0)
528
+
529
+ if counts.get("OVERRIDDEN"):
530
+ print("\n declared but NOT what the browser computed:")
531
+ for r in rows:
532
+ if r[6] == "OVERRIDDEN":
533
+ print(" %-4s %-22s %-16s declared %-18s got %s"
534
+ % (r[1], r[2][:22], r[3], r[4][:18], r[5][:34]))
535
+
536
+ # Audit findings collapse across breakpoints: the same headline reported at three
537
+ # widths is one defect, not three, and printing it three times buries the others.
538
+ seen: dict[tuple, dict] = {}
539
+ for f in findings:
540
+ key = (f["check"], f.get("node", ""), f.get("detail", ""))
541
+ seen.setdefault(key, dict(f, breakpoints=[]))["breakpoints"].append(
542
+ f["breakpoint"])
543
+ audit = sorted(seen.values(), key=lambda f: (f["level"] != "error", f["check"]))
544
+ errors = [f for f in audit if f["level"] == "error"]
545
+ hard += len(errors)
546
+
547
+ print("\ndesign audit (%d findings: %d error, %d warn)"
548
+ % (len(audit), len(errors), len(audit) - len(errors)))
549
+ for f in audit[:40]:
550
+ print(" %-5s %-22s %-26s %s"
551
+ % (f["level"], f["check"], (f.get("node") or "")[:26],
552
+ f.get("detail", "")))
553
+ if f.get("sample"):
554
+ print(" %s at %s" % (f["sample"], ",".join(f["breakpoints"])))
555
+ if len(audit) > 40:
556
+ print(" ... %d more, in the CSV" % (len(audit) - 40))
557
+
558
+ # Two tables, two files. They answer different questions, and a reader - or a row
559
+ # count in the release gate - should not have to find the blank line between them.
560
+ # The audit file is written even when it is empty: a header with no rows says
561
+ # "this ran and found nothing", where a missing file says nothing at all.
562
+ if a.csv:
563
+ with open(a.csv, "w", newline="", encoding="utf-8") as fh:
564
+ w = csv.writer(fh)
565
+ w.writerow(["url", "breakpoint", "attrID", "property", "declared",
566
+ "computed", "status"])
567
+ w.writerows(rows)
568
+ print("\nwrote %s (%d rows)" % (a.csv, len(rows)))
569
+ if a.audit:
570
+ with open(a.audit, "w", newline="", encoding="utf-8") as fh:
571
+ w = csv.writer(fh)
572
+ w.writerow(["url", "breakpoints", "check", "level", "node", "detail",
573
+ "sample"])
574
+ for f in audit:
575
+ w.writerow([f["url"], ",".join(f["breakpoints"]), f["check"],
576
+ f["level"], f.get("node", ""), f.get("detail", ""),
577
+ f.get("sample", "")])
578
+ print("wrote %s (%d findings)" % (a.audit, len(audit)))
579
+
580
+ print("\n%s" % ("PASS - every comparable declaration is what the browser "
581
+ "computed, and the audit is clean"
582
+ if not hard else
583
+ "FAIL - %d overridden declarations, %d audit errors"
584
+ % (counts.get("OVERRIDDEN", 0), len(errors))))
585
+ sys.exit(1 if hard else 0)
586
+
587
+
588
+ if __name__ == "__main__":
589
+ main()