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.
- package/README.ja.md +16 -1
- package/README.ko.md +16 -1
- package/README.md +17 -1
- package/README.zh-TW.md +14 -1
- package/SKILL.md +77 -5
- package/assets/templates/platforms/claude-ai.json +1 -1
- package/assets/templates/platforms/claude-code.json +1 -1
- package/assets/templates/platforms/codex-cli.json +1 -1
- package/assets/templates/platforms/copilot.json +1 -1
- package/assets/templates/platforms/gemini-cli.json +1 -1
- package/bin/check-release.mjs +20 -0
- package/data/browser-verification.csv +2238 -0
- package/data/data-class-hierarchy.csv +122 -0
- package/data/design-audit.csv +1 -0
- package/data/node-property-verification.csv +2 -2
- package/data/node-types.csv +123 -123
- package/package.json +1 -1
- package/references/failure-modes.md +52 -0
- package/sites/_moksa.py +35 -19
- package/sites/moksa.json +55 -51
- package/tools/build_page.py +34 -1
- package/tools/extract_node_types.py +26 -1
- package/tools/mo.py +686 -0
- package/tools/sweep_node_properties.py +41 -2
- package/tools/verify_browser.py +589 -0
|
@@ -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
|
|
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()
|