mosaic-headless 1.24.0 → 1.25.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/SKILL.md +7 -3
- 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/data/default-children.csv +26 -11
- package/data/dialog-form-verification.csv +2 -2
- package/data/mobile-audit.csv +55 -0
- package/data/navigation-verification.csv +27 -46
- package/data/token-verification.csv +16 -0
- package/package.json +1 -1
- package/references/responsive.md +53 -0
- package/references/styling.md +17 -3
- package/scripts/check-release.mjs +3 -1
- package/sites/_moksa.py +82 -12
- package/sites/moksa.json +12064 -466
- package/tools/audit_mobile.py +147 -0
- package/tools/extract_default_children.py +66 -4
- package/tools/verify_tokens.py +170 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Find the RWD defects a sideways-scroll check cannot see.
|
|
3
|
+
|
|
4
|
+
python tools/audit_mobile.py --base https://site/ --paths a/,b/ --widths 390,768 --csv data/mobile-audit.csv
|
|
5
|
+
|
|
6
|
+
"Does the document scroll horizontally" is the floor, not the bar. A page can pass
|
|
7
|
+
it and still be unusable on a phone: type below the legible floor, a grid that
|
|
8
|
+
never collapsed, a control too small to hit, a line of 60 Han characters, two
|
|
9
|
+
things sitting on top of each other.
|
|
10
|
+
"""
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from playwright.sync_api import sync_playwright
|
|
14
|
+
|
|
15
|
+
AUDIT = r"""() => {
|
|
16
|
+
const out = [];
|
|
17
|
+
const vw = document.documentElement.clientWidth;
|
|
18
|
+
const vis = el => {
|
|
19
|
+
const r = el.getBoundingClientRect();
|
|
20
|
+
const s = getComputedStyle(el);
|
|
21
|
+
return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' &&
|
|
22
|
+
s.display !== 'none' && parseFloat(s.opacity) > 0.05;
|
|
23
|
+
};
|
|
24
|
+
const txt = el => (el.textContent || '').trim();
|
|
25
|
+
// A slider lays its slides out side by side and clips them on purpose; so does
|
|
26
|
+
// anything else with overflow hidden. Measuring children against the VIEWPORT
|
|
27
|
+
// inside such a box reports the mechanism as a defect, 58 times on one page.
|
|
28
|
+
const clipped = el => {
|
|
29
|
+
for (let n = el.parentElement; n && n !== document.body; n = n.parentElement) {
|
|
30
|
+
const s = getComputedStyle(n);
|
|
31
|
+
if (/hidden|clip|auto|scroll/.test(s.overflow + s.overflowX)) return true;
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
for (const el of document.querySelectorAll('body *')) {
|
|
37
|
+
if (!vis(el)) continue;
|
|
38
|
+
const r = el.getBoundingClientRect();
|
|
39
|
+
const s = getComputedStyle(el);
|
|
40
|
+
const id = el.id || (el.className && String(el.className).split(' ')[0]) || el.tagName;
|
|
41
|
+
const leaf = !el.children.length || [...el.children].every(c => c.nodeType !== 1);
|
|
42
|
+
const t = txt(el);
|
|
43
|
+
|
|
44
|
+
const inClip = clipped(el);
|
|
45
|
+
|
|
46
|
+
// 1. anything wider than the viewport
|
|
47
|
+
if (r.width > vw + 1 && !inClip)
|
|
48
|
+
out.push(['WIDE', id, Math.round(r.width) + 'px vs viewport ' + vw]);
|
|
49
|
+
|
|
50
|
+
// 2. sticking out past either edge
|
|
51
|
+
if ((r.left < -1 || r.right > vw + 1) && !inClip)
|
|
52
|
+
out.push(['OUTSIDE', id, 'left=' + Math.round(r.left) + ' right=' + Math.round(r.right)]);
|
|
53
|
+
|
|
54
|
+
// 3. type too small to read on a phone
|
|
55
|
+
const fs = parseFloat(s.fontSize);
|
|
56
|
+
if (t && leaf && fs && fs < 11)
|
|
57
|
+
out.push(['TINY_TYPE', id, fs + 'px: ' + t.slice(0, 32)]);
|
|
58
|
+
|
|
59
|
+
// 4. a control too small to hit
|
|
60
|
+
if (/^(A|BUTTON)$/.test(el.tagName) || el.getAttribute('role') === 'button') {
|
|
61
|
+
if (r.height < 30 || r.width < 30)
|
|
62
|
+
out.push(['SMALL_TARGET', id,
|
|
63
|
+
Math.round(r.width) + 'x' + Math.round(r.height) + ': ' + t.slice(0, 24)]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 5. content clipped by its own box
|
|
67
|
+
const ownOverflow = s.overflow + ' ' + s.overflowX;
|
|
68
|
+
if (el.scrollWidth > el.clientWidth + 2 && !inClip &&
|
|
69
|
+
!/auto|scroll|hidden|clip/.test(ownOverflow))
|
|
70
|
+
out.push(['CLIPPED', id, el.scrollWidth + ' > ' + el.clientWidth]);
|
|
71
|
+
|
|
72
|
+
// 6. a grid that never collapsed
|
|
73
|
+
if (s.display === 'grid') {
|
|
74
|
+
const cols = s.gridTemplateColumns.split(' ').filter(x => x && x !== 'none');
|
|
75
|
+
if (cols.length > 1 && vw <= 430)
|
|
76
|
+
out.push(['GRID_NOT_COLLAPSED', id, cols.length + ' columns at ' + vw + 'px']);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 7. a very long measure - Han text past ~40 chars per line reads badly
|
|
80
|
+
if (t && leaf && fs) {
|
|
81
|
+
const perLine = r.width / fs;
|
|
82
|
+
if (perLine > 42 && t.length > 40)
|
|
83
|
+
out.push(['LONG_MEASURE', id, Math.round(perLine) + ' chars/line']);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}"""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def main():
|
|
91
|
+
import argparse, csv as _csv
|
|
92
|
+
ap = argparse.ArgumentParser(description=__doc__,
|
|
93
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
94
|
+
ap.add_argument("--base", required=True)
|
|
95
|
+
ap.add_argument("--paths", required=True,
|
|
96
|
+
help="comma-separated, relative to --base")
|
|
97
|
+
ap.add_argument("--widths", default="390,768")
|
|
98
|
+
ap.add_argument("--csv")
|
|
99
|
+
ap.add_argument("--ignore", default="",
|
|
100
|
+
help="comma-separated KIND or KIND:id to accept as intended")
|
|
101
|
+
a = ap.parse_args()
|
|
102
|
+
url_base = a.base
|
|
103
|
+
slugs = a.paths.split(",")
|
|
104
|
+
ignore = {x.strip() for x in a.ignore.split(",") if x.strip()}
|
|
105
|
+
rows = []
|
|
106
|
+
found = {}
|
|
107
|
+
widths = [int(x) for x in a.widths.split(",")]
|
|
108
|
+
with sync_playwright() as pw:
|
|
109
|
+
b = pw.chromium.launch()
|
|
110
|
+
for width in widths:
|
|
111
|
+
for slug in slugs:
|
|
112
|
+
c = b.new_context(viewport={"width": width, "height": 780},
|
|
113
|
+
device_scale_factor=2, is_mobile=True,
|
|
114
|
+
has_touch=True)
|
|
115
|
+
p = c.new_page()
|
|
116
|
+
p.goto(url_base + slug, wait_until="load")
|
|
117
|
+
p.wait_for_timeout(2200)
|
|
118
|
+
p.evaluate("()=>document.querySelectorAll('dialog').forEach(d=>d.open&&d.close())")
|
|
119
|
+
p.wait_for_timeout(300)
|
|
120
|
+
agg = {}
|
|
121
|
+
for kind, who, detail in p.evaluate(AUDIT):
|
|
122
|
+
if kind in ignore or ("%s:%s" % (kind, who)) in ignore:
|
|
123
|
+
continue
|
|
124
|
+
agg.setdefault(kind, []).append((who, detail))
|
|
125
|
+
rows.append([slug, str(width), kind, who, detail])
|
|
126
|
+
found[slug] = agg
|
|
127
|
+
print("== %s (%dpx)" % (slug or "/", width))
|
|
128
|
+
if not agg:
|
|
129
|
+
print(" clean")
|
|
130
|
+
for kind in sorted(agg):
|
|
131
|
+
items = agg[kind]
|
|
132
|
+
print(" %-20s %d" % (kind, len(items)))
|
|
133
|
+
for who, detail in items[:4]:
|
|
134
|
+
print(" %-26s %s" % (who[:26], detail[:70]))
|
|
135
|
+
c.close()
|
|
136
|
+
b.close()
|
|
137
|
+
if a.csv:
|
|
138
|
+
with open(a.csv, "w", newline="", encoding="utf-8") as fh:
|
|
139
|
+
w = _csv.writer(fh)
|
|
140
|
+
w.writerow(["path", "width", "finding", "element", "detail"])
|
|
141
|
+
w.writerows(rows)
|
|
142
|
+
print("\nwrote %s (%d findings)" % (a.csv, len(rows)))
|
|
143
|
+
return 1 if rows else 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__":
|
|
147
|
+
sys.exit(main())
|
|
@@ -20,6 +20,20 @@ server side complains. This table is where that requirement is written down.
|
|
|
20
20
|
|
|
21
21
|
The source of truth is the `get<Something>DefaultData()` static on each type factory,
|
|
22
22
|
which is what the editor calls when you insert one of these from the UI.
|
|
23
|
+
|
|
24
|
+
## The other half: children a type HEALS into itself
|
|
25
|
+
|
|
26
|
+
A composite can also acquire its parts after the fact. `ModalElementMResource::onHeal()`
|
|
27
|
+
inserts a `modal-overlay` when the author left one out - "a required structural part,
|
|
28
|
+
non-deletable, one per Modal" - and `LoopElementMResource::onHeal()` does the same with
|
|
29
|
+
`loop-items` and `loop-no-result`. Those children never appear in a `getDefaultData()`
|
|
30
|
+
body, so a table built only from the statics above says a `loop` has no default
|
|
31
|
+
children at all, and anything consulting it (the placement guard in `build_page.py`,
|
|
32
|
+
for one) then refuses a correctly-built loop.
|
|
33
|
+
|
|
34
|
+
So the MResource classes are read too, and a child found inside an `onHeal()` body is
|
|
35
|
+
recorded with `source` = `heal`. The distinction is worth keeping: a declared default
|
|
36
|
+
is what you GET when you insert the type, a healed one is what you CANNOT avoid.
|
|
23
37
|
"""
|
|
24
38
|
import csv
|
|
25
39
|
import os
|
|
@@ -32,6 +46,13 @@ RE_DEFAULT = re.compile(
|
|
|
32
46
|
RE_TYPE = re.compile(r'"type"\s*=>\s*"([^"]+)"')
|
|
33
47
|
RE_CHILD_CALL = re.compile(r"(\w+)ElementTypeFactory::get(\w+)DefaultData|(\w+)NodeTypeFactory::get(\w+)DefaultData")
|
|
34
48
|
|
|
49
|
+
# `protected function onHeal(...) { ... }` - the body is taken to the next
|
|
50
|
+
# method at the same indent, which is how every one of these files is laid out.
|
|
51
|
+
RE_HEAL = re.compile(r"function\s+onHeal\s*\([^)]*\)\s*:\s*void\s*\{(.*?)\n \}", re.S)
|
|
52
|
+
# the MResource knows its own slug through the factory it names in use statements,
|
|
53
|
+
# so the owning TYPE is taken from the class name instead
|
|
54
|
+
RE_MRESOURCE_CLASS = re.compile(r"class\s+(\w+)ElementMResource\b")
|
|
55
|
+
|
|
35
56
|
|
|
36
57
|
def read(path):
|
|
37
58
|
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
@@ -76,16 +97,57 @@ def extract(plugin_root, out_dir):
|
|
|
76
97
|
"declared_in": os.path.relpath(path, plugin_root).replace(os.sep, "/"),
|
|
77
98
|
})
|
|
78
99
|
|
|
79
|
-
|
|
100
|
+
# ---- and the ones a type heals into itself
|
|
101
|
+
stem_by_class = {}
|
|
102
|
+
for path, src in files:
|
|
103
|
+
m = re.search(r"class\s+(\w+)ElementTypeFactory", src)
|
|
104
|
+
if m:
|
|
105
|
+
stem_by_class[m.group(1)] = stem_to_slug.get(m.group(1), "")
|
|
106
|
+
|
|
107
|
+
for dirpath, _dirs, names in os.walk(node_root):
|
|
108
|
+
for fn in sorted(names):
|
|
109
|
+
if not fn.endswith("ElementMResource.php"):
|
|
110
|
+
continue
|
|
111
|
+
path = os.path.join(dirpath, fn)
|
|
112
|
+
src = read(path)
|
|
113
|
+
owner_stem = RE_MRESOURCE_CLASS.search(src)
|
|
114
|
+
if not owner_stem:
|
|
115
|
+
continue
|
|
116
|
+
owner = stem_to_slug.get(owner_stem.group(1))
|
|
117
|
+
if not owner:
|
|
118
|
+
continue
|
|
119
|
+
healed = []
|
|
120
|
+
for body in RE_HEAL.findall(src):
|
|
121
|
+
for m in RE_CHILD_CALL.finditer(body):
|
|
122
|
+
stem = m.group(1) or m.group(3)
|
|
123
|
+
slug = stem_to_slug.get(stem, stem.lower())
|
|
124
|
+
if slug != owner and slug not in healed:
|
|
125
|
+
healed.append(slug)
|
|
126
|
+
if healed:
|
|
127
|
+
rows.append({
|
|
128
|
+
"type": owner,
|
|
129
|
+
"default_children": "|".join(healed),
|
|
130
|
+
"child_count": len(healed),
|
|
131
|
+
"source": "heal",
|
|
132
|
+
"declared_in": os.path.relpath(path, plugin_root).replace(os.sep, "/"),
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
for r in rows:
|
|
136
|
+
r.setdefault("source", "default")
|
|
137
|
+
|
|
138
|
+
rows.sort(key=lambda r: (r["type"], r["source"]))
|
|
80
139
|
os.makedirs(out_dir, exist_ok=True)
|
|
81
140
|
out = os.path.join(out_dir, "default-children.csv")
|
|
82
141
|
with open(out, "w", newline="", encoding="utf-8") as fh:
|
|
83
|
-
w = csv.DictWriter(fh, fieldnames=["type", "default_children", "child_count",
|
|
142
|
+
w = csv.DictWriter(fh, fieldnames=["type", "default_children", "child_count",
|
|
143
|
+
"source", "declared_in"])
|
|
84
144
|
w.writeheader()
|
|
85
145
|
w.writerows(rows)
|
|
86
|
-
print("composite types with a
|
|
146
|
+
print("composite types with a required internal structure: %d (%d declared, "
|
|
147
|
+
"%d healed in)" % (len(rows), sum(1 for r in rows if r["source"] == "default"),
|
|
148
|
+
sum(1 for r in rows if r["source"] == "heal")))
|
|
87
149
|
for r in rows:
|
|
88
|
-
print(" %-26s -> %s" % (r["type"], r["default_children"]))
|
|
150
|
+
print(" %-26s %-8s -> %s" % (r["type"], r["source"], r["default_children"]))
|
|
89
151
|
|
|
90
152
|
|
|
91
153
|
if __name__ == "__main__":
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Hold a page's design tokens against what the browser actually computed.
|
|
3
|
+
|
|
4
|
+
python tools/verify_tokens.py --url https://site/tokens/ \\
|
|
5
|
+
--prefix --mk- --csv data/token-verification.csv
|
|
6
|
+
|
|
7
|
+
A collection variable is the one part of the styling system with a visible failure
|
|
8
|
+
mode that looks like success: a wrong `skinsData` shape still creates the variable,
|
|
9
|
+
still emits the `:root` declaration, and simply serves the sub-factory's default -
|
|
10
|
+
`#FFF` for a colour. The page renders, nothing errors, and the token is white. So
|
|
11
|
+
"the declaration exists" is not the check; "the declaration exists AND something on
|
|
12
|
+
the page resolved to it AND it is not the default" is.
|
|
13
|
+
|
|
14
|
+
Three things are asserted, each of which has gone wrong at least once:
|
|
15
|
+
|
|
16
|
+
- every `--` custom property on `:root` is declared exactly ONCE. Changing a
|
|
17
|
+
variable's value does not retire the old declaration, so duplicates are the
|
|
18
|
+
signature of an orphan left behind by an earlier build.
|
|
19
|
+
- every property's value is non-empty and is not the bare white default.
|
|
20
|
+
- every element that was authored to reference a token computes to that token's
|
|
21
|
+
value. This is what separates "the token exists" from "the token is in use" -
|
|
22
|
+
a page can carry a perfect `:root` and point at none of it.
|
|
23
|
+
"""
|
|
24
|
+
import argparse
|
|
25
|
+
import csv
|
|
26
|
+
import re
|
|
27
|
+
import sys
|
|
28
|
+
|
|
29
|
+
from playwright.sync_api import sync_playwright
|
|
30
|
+
|
|
31
|
+
ROOT_VARS = """() => {
|
|
32
|
+
// Every :root rule in every stylesheet the document actually loaded, in order,
|
|
33
|
+
// so a property declared twice is visible as two entries rather than collapsing.
|
|
34
|
+
const out = [];
|
|
35
|
+
for (const sheet of document.styleSheets) {
|
|
36
|
+
let rules;
|
|
37
|
+
try { rules = sheet.cssRules; } catch (e) { continue; }
|
|
38
|
+
for (const r of rules || []) {
|
|
39
|
+
if (!r.selectorText || !/(^|,)\\s*:root\\s*($|,)/.test(r.selectorText)) continue;
|
|
40
|
+
for (const name of r.style) {
|
|
41
|
+
if (name.startsWith('--')) out.push([name, r.style.getPropertyValue(name).trim()]);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}"""
|
|
47
|
+
|
|
48
|
+
USES = """(prefix) => {
|
|
49
|
+
// Which elements resolve to a token, and to what. getComputedStyle gives the
|
|
50
|
+
// RESOLVED value, so this is the end of the pipeline, not the authored intent.
|
|
51
|
+
const cs = getComputedStyle(document.documentElement);
|
|
52
|
+
const want = {};
|
|
53
|
+
for (const name of cs) if (name.startsWith(prefix)) want[name] = cs.getPropertyValue(name).trim();
|
|
54
|
+
const seen = {};
|
|
55
|
+
for (const el of document.querySelectorAll('[id]')) {
|
|
56
|
+
const s = getComputedStyle(el);
|
|
57
|
+
for (const prop of ['color', 'background-color', 'border-top-color']) {
|
|
58
|
+
const v = s.getPropertyValue(prop).trim();
|
|
59
|
+
for (const [name, val] of Object.entries(want)) {
|
|
60
|
+
if (!val) continue;
|
|
61
|
+
if (norm(v) && norm(v) === norm(val)) (seen[name] = seen[name] || []).push(el.id);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function norm(c) {
|
|
66
|
+
if (!c) return '';
|
|
67
|
+
const m = c.match(/rgba?\\(([^)]+)\\)/);
|
|
68
|
+
if (!m) return c.replace(/\\s+/g, '');
|
|
69
|
+
const p = m[1].split(',').map(x => parseFloat(x));
|
|
70
|
+
if (p.length === 4 && p[3] === 0) return ''; // transparent matches nothing
|
|
71
|
+
return p.slice(0, 3).map(Math.round).join(',');
|
|
72
|
+
}
|
|
73
|
+
return {declared: want, used: seen};
|
|
74
|
+
}"""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def main():
|
|
78
|
+
ap = argparse.ArgumentParser(description=__doc__,
|
|
79
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
80
|
+
ap.add_argument("--url", required=True)
|
|
81
|
+
ap.add_argument("--prefix", default="--",
|
|
82
|
+
help="only tokens whose custom property starts with this are "
|
|
83
|
+
"judged; a theme shares :root with WordPress and the "
|
|
84
|
+
"block editor, whose variables are not yours")
|
|
85
|
+
ap.add_argument("--csv")
|
|
86
|
+
a = ap.parse_args()
|
|
87
|
+
|
|
88
|
+
rows, fails = [], []
|
|
89
|
+
|
|
90
|
+
def check(name, ok, detail=""):
|
|
91
|
+
rows.append([name, "PASS" if ok else "FAIL", detail])
|
|
92
|
+
print(" %-26s %-5s %s" % (name, "PASS" if ok else "FAIL", detail))
|
|
93
|
+
if not ok:
|
|
94
|
+
fails.append(name)
|
|
95
|
+
|
|
96
|
+
with sync_playwright() as pw:
|
|
97
|
+
b = pw.chromium.launch()
|
|
98
|
+
pg = b.new_context(viewport={"width": 1280, "height": 900}).new_page()
|
|
99
|
+
pg.goto(a.url, wait_until="load")
|
|
100
|
+
pg.wait_for_timeout(1500)
|
|
101
|
+
|
|
102
|
+
declared = [(n, v) for n, v in pg.evaluate(ROOT_VARS)
|
|
103
|
+
if n.startswith(a.prefix)]
|
|
104
|
+
info = pg.evaluate(USES, a.prefix)
|
|
105
|
+
b.close()
|
|
106
|
+
|
|
107
|
+
check("ROOT_PRESENT", bool(declared),
|
|
108
|
+
"%d custom properties on :root matching %r" % (len(declared), a.prefix))
|
|
109
|
+
|
|
110
|
+
counts = {}
|
|
111
|
+
for n, _v in declared:
|
|
112
|
+
counts[n] = counts.get(n, 0) + 1
|
|
113
|
+
dupes = {n: c for n, c in counts.items() if c > 1}
|
|
114
|
+
check("NO_DUPLICATES", not dupes,
|
|
115
|
+
"every property declared once" if not dupes
|
|
116
|
+
else "declared more than once (an earlier value was never retired): %s"
|
|
117
|
+
% ", ".join("%s x%d" % kv for kv in sorted(dupes.items())))
|
|
118
|
+
|
|
119
|
+
blank = [n for n, v in declared if not v]
|
|
120
|
+
check("NO_EMPTY", not blank,
|
|
121
|
+
"every token has a value" if not blank else "empty: %s" % ", ".join(blank))
|
|
122
|
+
|
|
123
|
+
# #FFF is what a colour variable serves when its skinsData never arrived. It is
|
|
124
|
+
# a legitimate value too, so this reports rather than fails on its own.
|
|
125
|
+
whites = [n for n, v in declared
|
|
126
|
+
if re.sub(r"\s+", "", v).lower() in ("#fff", "#ffffff",
|
|
127
|
+
"rgb(255,255,255)")]
|
|
128
|
+
check("NOT_DEFAULT_WHITE", True,
|
|
129
|
+
"none" if not whites else
|
|
130
|
+
"white, which is also the value a colour serves when skinsData never "
|
|
131
|
+
"arrived - confirm these are intended: %s" % ", ".join(whites))
|
|
132
|
+
|
|
133
|
+
# Only a COLOUR token can be found by comparing computed colours. A theme's
|
|
134
|
+
# `:root` also carries numbers that drive animations and lengths - this page's
|
|
135
|
+
# clock keeps its state in --mk-m/-s/-n - and judging those by colour would
|
|
136
|
+
# report a permanent, meaningless failure. Split them and say which is which.
|
|
137
|
+
used = info["used"]
|
|
138
|
+
is_colour = re.compile(r"^(#[0-9a-fA-F]{3,8}|rgba?\(|hsla?\()")
|
|
139
|
+
colours = {n: v for n, v in info["declared"].items() if is_colour.match(v)}
|
|
140
|
+
others = {n: v for n, v in info["declared"].items() if n not in colours}
|
|
141
|
+
|
|
142
|
+
unused = [n for n in colours if n not in used]
|
|
143
|
+
check("ALL_COLOUR_TOKENS_USED", not unused,
|
|
144
|
+
"all %d colour tokens are resolved by at least one element" % len(colours)
|
|
145
|
+
if not unused else
|
|
146
|
+
"declared but nothing on this page resolves to them: %s" % ", ".join(unused))
|
|
147
|
+
check("NON_COLOUR_TOKENS", True,
|
|
148
|
+
"none" if not others else
|
|
149
|
+
"%d not judged by colour (they are numbers or lengths): %s"
|
|
150
|
+
% (len(others), ", ".join("%s=%s" % (n, v or "''") for n, v
|
|
151
|
+
in sorted(others.items()))))
|
|
152
|
+
|
|
153
|
+
for name in sorted(colours):
|
|
154
|
+
ids = used.get(name, [])
|
|
155
|
+
check("USED/%s" % name, bool(ids),
|
|
156
|
+
"%d element(s), e.g. %s" % (len(ids), ", ".join(ids[:3]))
|
|
157
|
+
if ids else "no element on the page computes to %s" % colours[name])
|
|
158
|
+
|
|
159
|
+
print("\n%d of %d checks passed" % (len(rows) - len(fails), len(rows)))
|
|
160
|
+
if a.csv:
|
|
161
|
+
with open(a.csv, "w", newline="", encoding="utf-8") as fh:
|
|
162
|
+
w = csv.writer(fh)
|
|
163
|
+
w.writerow(["check", "result", "detail"])
|
|
164
|
+
w.writerows(rows)
|
|
165
|
+
print("wrote %s" % a.csv)
|
|
166
|
+
sys.exit(1 if fails else 0)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
if __name__ == "__main__":
|
|
170
|
+
main()
|