mosaic-headless 1.25.1 → 1.26.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 +5 -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/component-wall-verification.csv +12 -0
- package/data/mobile-audit.csv +4 -0
- package/data/navigation-verification.csv +15 -13
- package/package.json +1 -1
- package/references/design-system.md +36 -0
- package/scripts/check-release.mjs +3 -2
- package/sites/_moksa.py +13 -3
- package/sites/moksa.json +3350 -106
- package/tools/audit_mobile.py +5 -0
- package/tools/verify_components.py +145 -0
package/tools/audit_mobile.py
CHANGED
|
@@ -42,6 +42,11 @@ AUDIT = r"""() => {
|
|
|
42
42
|
const t = txt(el);
|
|
43
43
|
|
|
44
44
|
const inClip = clipped(el);
|
|
45
|
+
// `left:-9999px` is the standard way to put something off screen for
|
|
46
|
+
// assistive tech only - a form's honeypot, a skip link, a visually-hidden
|
|
47
|
+
// label. It is deliberate, so it is not "outside the viewport" in the sense
|
|
48
|
+
// this audit means.
|
|
49
|
+
if (r.right < -1000 || r.left > vw + 1000) continue;
|
|
45
50
|
|
|
46
51
|
// 1. anything wider than the viewport
|
|
47
52
|
if (r.width > vw + 1 && !inClip)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Drive the interactive components on a page and record what they actually did.
|
|
3
|
+
|
|
4
|
+
python tools/verify_components.py --url https://site/components/ \
|
|
5
|
+
--csv data/component-wall-verification.csv
|
|
6
|
+
|
|
7
|
+
A page that says "these all work" is worth exactly as much as the last time
|
|
8
|
+
someone pressed them. Each component here is operated the way a visitor would and
|
|
9
|
+
asserted on its RESULT, not on its markup:
|
|
10
|
+
|
|
11
|
+
tabs clicking tab 3 hides pane 1 and shows pane 3
|
|
12
|
+
accordion clicking a title opens that item and leaves the others shut; the
|
|
13
|
+
title is keyboard-operable, which is the plugin's doing, not ours
|
|
14
|
+
form the named fields exist, required is on them, the success screen is
|
|
15
|
+
hidden until it is needed, and the honeypot Mosaic adds is present
|
|
16
|
+
map the OSM frame carries the coordinates that were authored - a wrong
|
|
17
|
+
property NAME is dropped in silence and the map centres on its
|
|
18
|
+
default, which is Times Square and looks deliberate
|
|
19
|
+
|
|
20
|
+
Each check names the attrIDs it needs, so a page that renames something fails
|
|
21
|
+
here rather than quietly losing a demonstration.
|
|
22
|
+
"""
|
|
23
|
+
import argparse
|
|
24
|
+
import csv
|
|
25
|
+
import re
|
|
26
|
+
import sys
|
|
27
|
+
|
|
28
|
+
from playwright.sync_api import sync_playwright
|
|
29
|
+
|
|
30
|
+
VISIBLE = """(id) => {
|
|
31
|
+
const e = document.getElementById(id);
|
|
32
|
+
if (!e) return null;
|
|
33
|
+
const r = e.getBoundingClientRect(), s = getComputedStyle(e);
|
|
34
|
+
return r.height > 2 && s.display !== 'none' && s.visibility !== 'hidden'
|
|
35
|
+
&& parseFloat(s.opacity) > 0.05;
|
|
36
|
+
}"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main():
|
|
40
|
+
ap = argparse.ArgumentParser(description=__doc__,
|
|
41
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
42
|
+
ap.add_argument("--url", required=True)
|
|
43
|
+
ap.add_argument("--csv")
|
|
44
|
+
ap.add_argument("--tabs", default="cp-tab-%d/cp-pane-%d:4",
|
|
45
|
+
help="tab/pane attrID patterns and how many")
|
|
46
|
+
ap.add_argument("--faq", default="cp-faq-q-%d/cp-faq-a-%d:4")
|
|
47
|
+
ap.add_argument("--form", default="cp-form")
|
|
48
|
+
ap.add_argument("--map", default="cp-map")
|
|
49
|
+
a = ap.parse_args()
|
|
50
|
+
|
|
51
|
+
rows, fails = [], []
|
|
52
|
+
|
|
53
|
+
def check(name, ok, detail=""):
|
|
54
|
+
rows.append([name, "PASS" if ok else "FAIL", detail])
|
|
55
|
+
print(" %-28s %-5s %s" % (name, "PASS" if ok else "FAIL", detail))
|
|
56
|
+
if not ok:
|
|
57
|
+
fails.append(name)
|
|
58
|
+
|
|
59
|
+
def split(spec):
|
|
60
|
+
pat, n = spec.rsplit(":", 1)
|
|
61
|
+
tab, pane = pat.split("/")
|
|
62
|
+
return tab, pane, int(n)
|
|
63
|
+
|
|
64
|
+
tab_pat, pane_pat, n_tabs = split(a.tabs)
|
|
65
|
+
q_pat, ans_pat, n_faq = split(a.faq)
|
|
66
|
+
|
|
67
|
+
with sync_playwright() as pw:
|
|
68
|
+
b = pw.chromium.launch()
|
|
69
|
+
p = b.new_context(viewport={"width": 1280, "height": 900}).new_page()
|
|
70
|
+
p.goto(a.url, wait_until="load")
|
|
71
|
+
p.wait_for_timeout(2200)
|
|
72
|
+
|
|
73
|
+
# ---- tabs
|
|
74
|
+
shown = [i for i in range(n_tabs) if p.evaluate(VISIBLE, pane_pat % i)]
|
|
75
|
+
check("TABS/one_pane_at_rest", shown == [0],
|
|
76
|
+
"visible panes at rest: %s" % shown)
|
|
77
|
+
last = n_tabs - 1
|
|
78
|
+
p.click("#" + tab_pat % last)
|
|
79
|
+
p.wait_for_timeout(600)
|
|
80
|
+
shown = [i for i in range(n_tabs) if p.evaluate(VISIBLE, pane_pat % i)]
|
|
81
|
+
check("TABS/click_switches", shown == [last],
|
|
82
|
+
"clicked tab %d -> panes %s" % (last, shown))
|
|
83
|
+
active = p.evaluate("""() => {
|
|
84
|
+
const t = document.querySelector('.m-tab--active');
|
|
85
|
+
if (!t) return null;
|
|
86
|
+
const ps = [...t.querySelectorAll('p')].map(e => getComputedStyle(e).color);
|
|
87
|
+
return {bg: getComputedStyle(t).backgroundColor, labels: ps};
|
|
88
|
+
}""")
|
|
89
|
+
# the active tab is darkened by the plugin; if the label was not restyled
|
|
90
|
+
# with ___tab--active___descendants it is ink on ink and unreadable
|
|
91
|
+
readable = bool(active) and active["bg"] != (active["labels"] or [""])[0]
|
|
92
|
+
check("TABS/active_label_readable", readable, str(active))
|
|
93
|
+
|
|
94
|
+
# ---- accordion
|
|
95
|
+
open_now = [i for i in range(n_faq) if p.evaluate(VISIBLE, ans_pat % i)]
|
|
96
|
+
check("ACCORDION/shut_at_rest", open_now == [], "open: %s" % open_now)
|
|
97
|
+
p.click("#" + q_pat % 1)
|
|
98
|
+
p.wait_for_timeout(700)
|
|
99
|
+
open_now = [i for i in range(n_faq) if p.evaluate(VISIBLE, ans_pat % i)]
|
|
100
|
+
check("ACCORDION/click_opens_one", open_now == [1],
|
|
101
|
+
"clicked 1 -> open %s" % open_now)
|
|
102
|
+
tabindex = p.evaluate("(id) => document.getElementById(id).getAttribute('tabindex')",
|
|
103
|
+
q_pat % 1)
|
|
104
|
+
check("ACCORDION/keyboard_reachable", tabindex == "0",
|
|
105
|
+
"title tabindex=%r - the plugin puts it in the tab order" % tabindex)
|
|
106
|
+
|
|
107
|
+
# ---- form
|
|
108
|
+
fields = p.evaluate("""(id) => [...document.querySelectorAll(
|
|
109
|
+
'#' + id + ' input, #' + id + ' textarea')].map(
|
|
110
|
+
e => ({name: e.getAttribute('name'), req: e.required,
|
|
111
|
+
tag: e.tagName.toLowerCase()}))""", a.form)
|
|
112
|
+
named = [f["name"] for f in fields if f["name"]]
|
|
113
|
+
honeypot = [n for n in named if n.startswith("mosaic_hp")]
|
|
114
|
+
real = [n for n in named if not n.startswith("mosaic_hp")]
|
|
115
|
+
check("FORM/fields", len(real) >= 3, "named fields: %s" % real)
|
|
116
|
+
check("FORM/required", all(f["req"] for f in fields
|
|
117
|
+
if f["name"] in real),
|
|
118
|
+
"every author field is required")
|
|
119
|
+
check("FORM/honeypot", bool(honeypot),
|
|
120
|
+
"Mosaic adds its own spam trap: %s" % honeypot)
|
|
121
|
+
|
|
122
|
+
# ---- map
|
|
123
|
+
src = p.evaluate("""(id) => { const f = document.querySelector('#' + id + ' iframe');
|
|
124
|
+
return f ? f.getAttribute('src') : null; }""", a.map)
|
|
125
|
+
m = re.search(r"marker=([-\d.]+),([-\d.]+)", src or "")
|
|
126
|
+
times_square = bool(m) and m.group(1).startswith("40.75")
|
|
127
|
+
check("MAP/frame", bool(src), "renders an openstreetmap iframe")
|
|
128
|
+
check("MAP/coordinates_applied", bool(m) and not times_square,
|
|
129
|
+
("marker=%s,%s" % m.groups()) if m else "no marker in %r" % (src or "")[:60]
|
|
130
|
+
+ (" - this is the default, so the property name was wrong"
|
|
131
|
+
if times_square else ""))
|
|
132
|
+
b.close()
|
|
133
|
+
|
|
134
|
+
print("\n%d checks, %d FAIL" % (len(rows), len(fails)))
|
|
135
|
+
if a.csv:
|
|
136
|
+
with open(a.csv, "w", newline="", encoding="utf-8") as fh:
|
|
137
|
+
w = csv.writer(fh)
|
|
138
|
+
w.writerow(["check", "result", "detail"])
|
|
139
|
+
w.writerows(rows)
|
|
140
|
+
print("wrote %s" % a.csv)
|
|
141
|
+
sys.exit(1 if fails else 0)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
if __name__ == "__main__":
|
|
145
|
+
main()
|