mosaic-headless 1.25.0 → 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 +24 -20
- package/data/navigation-verification.csv +16 -19
- package/data/placement-rules.csv +127 -127
- package/data/slider-verification.csv +15 -15
- package/package.json +1 -1
- package/references/design-system.md +68 -0
- package/scripts/check-release.mjs +3 -2
- package/sites/_moksa.py +114 -3
- package/sites/moksa.json +5654 -1033
- package/tools/audit_mobile.py +5 -0
- package/tools/build_page.py +8 -1
- package/tools/extract_placement.py +12 -1
- 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)
|
package/tools/build_page.py
CHANGED
|
@@ -173,7 +173,14 @@ class Surface:
|
|
|
173
173
|
# parent rule would refuse every piece of text on the page.
|
|
174
174
|
if parent_type and not t.startswith("wysiwyg-"):
|
|
175
175
|
rule = self.rules.get(parent_type, {})
|
|
176
|
-
|
|
176
|
+
# `rule == "none"` means canBeParentFor() was never overridden, which
|
|
177
|
+
# is the editor's drop rule and not the same question as "can this
|
|
178
|
+
# hold children". `takes_children` records the factory signature, and
|
|
179
|
+
# a type whose getDefaultData takes `$children` is a container
|
|
180
|
+
# whatever the drop rule says - `dropdown-wrapper` is exactly that.
|
|
181
|
+
if (rule.get("rule") == "none"
|
|
182
|
+
and not rule.get("takes_children")
|
|
183
|
+
and t not in self.default_children.get(parent_type, [])):
|
|
177
184
|
problems.append("%s is a leaf and accepts no children (tried %s)" % (parent_type, t))
|
|
178
185
|
elif rule.get("rule") == "allow":
|
|
179
186
|
allowed = rule["allowed_children"].split("|") + self.default_children.get(parent_type, [])
|
|
@@ -39,6 +39,15 @@ RE_CAN_BE_PARENT = re.compile(
|
|
|
39
39
|
RE_INSTANCEOF = re.compile(r"instanceof\s+(\w+)")
|
|
40
40
|
RE_CTOR = re.compile(r"parent::__construct\s*\(\s*\$\w+\s*,\s*'([^']+)'", re.S)
|
|
41
41
|
|
|
42
|
+
# `canBeParentFor()` answers "will the editor let me DROP this in here", and a type
|
|
43
|
+
# that never overrides it reads as a leaf. That is not the same question as "can
|
|
44
|
+
# this hold children at all": `dropdown-wrapper` says no to the first and yet its
|
|
45
|
+
# own factory is `getDropdownWrapperElementDefaultData($data, $children)` and the
|
|
46
|
+
# editor fills it. A tool that refuses writes on the strength of the `rule` column
|
|
47
|
+
# alone therefore refuses correct structures, so the signature is recorded too.
|
|
48
|
+
RE_TAKES_CHILDREN = re.compile(
|
|
49
|
+
r"function\s+get\w*DefaultData\s*\([^)]*\$children", re.S)
|
|
50
|
+
|
|
42
51
|
|
|
43
52
|
def read(path):
|
|
44
53
|
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
@@ -111,13 +120,15 @@ def extract(plugin_root, out_dir):
|
|
|
111
120
|
"rule": rule,
|
|
112
121
|
"allowed_children": "|".join(allowed),
|
|
113
122
|
"nested_rule": nested,
|
|
123
|
+
"takes_children": "yes" if RE_TAKES_CHILDREN.search(src) else "",
|
|
114
124
|
"declared_in": os.path.relpath(defined_in or path, plugin_root).replace(os.sep, "/"),
|
|
115
125
|
}
|
|
116
126
|
)
|
|
117
127
|
|
|
118
128
|
os.makedirs(out_dir, exist_ok=True)
|
|
119
129
|
with open(os.path.join(out_dir, "placement-rules.csv"), "w", newline="", encoding="utf-8") as fh:
|
|
120
|
-
w = csv.DictWriter(fh, fieldnames=["type", "rule", "allowed_children", "nested_rule",
|
|
130
|
+
w = csv.DictWriter(fh, fieldnames=["type", "rule", "allowed_children", "nested_rule",
|
|
131
|
+
"takes_children", "declared_in"])
|
|
121
132
|
w.writeheader()
|
|
122
133
|
w.writerows(rows)
|
|
123
134
|
|
|
@@ -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()
|