mosaic-headless 1.26.1 → 1.27.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 -6
- 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 +4 -2
- package/data/form-surface-verification.csv +9 -0
- package/data/mobile-audit.csv +2 -0
- package/data/navigation-verification.csv +17 -14
- package/data/node-types.csv +2 -0
- package/data/placement-rules.csv +129 -127
- package/package.json +1 -1
- package/references/failure-modes.md +27 -0
- package/references/placement.md +38 -0
- package/scripts/check-release.mjs +4 -3
- package/sites/_moksa.py +6 -0
- package/sites/moksa.json +1986 -104
- package/tools/build_page.py +18 -5
- package/tools/extract_node_types.py +7 -1
- package/tools/extract_placement.py +23 -3
- package/tools/verify_components.py +95 -52
package/tools/build_page.py
CHANGED
|
@@ -95,7 +95,7 @@ class Surface:
|
|
|
95
95
|
self.default_children = {r["type"]: r["default_children"].split("|")
|
|
96
96
|
for r in load_csv("default-children.csv")}
|
|
97
97
|
|
|
98
|
-
def check(self, parent_type, node, force):
|
|
98
|
+
def check(self, parent_type, node, force, ancestors=()):
|
|
99
99
|
t = node["type"]
|
|
100
100
|
problems = []
|
|
101
101
|
if t not in self.types:
|
|
@@ -157,12 +157,23 @@ class Surface:
|
|
|
157
157
|
# registered types - slider/slider-slides, accordion/accordion-item,
|
|
158
158
|
# form/form-input - and which actually carries one of them is not the thing
|
|
159
159
|
# that was measured, and the verdict does not apply to it.
|
|
160
|
+
rule_for_t = self.rules.get(t, {})
|
|
160
161
|
family = [x for x in self.types if x.startswith(t + "-")]
|
|
161
162
|
kids = [c.get("type") for c in (node.get("children") or []) if isinstance(c, dict)]
|
|
162
163
|
nested_as_family = bool(family) and any(k in family for k in kids)
|
|
163
164
|
|
|
165
|
+
# `nested_under` is what `canBeNestedChildFor()` walks the parent chain
|
|
166
|
+
# looking for: `radio-input` needs a `choice` above it, `label` needs a
|
|
167
|
+
# `field`, `loop-pagination` needs a `loop`. The names often share no
|
|
168
|
+
# prefix, so this is the only way to tell a correct placement from the
|
|
169
|
+
# bare one that was measured.
|
|
170
|
+
needs = (rule_for_t.get("nested_under") or "").split("|") if rule_for_t else []
|
|
171
|
+
nested_ok = bool(needs and needs != [""] and
|
|
172
|
+
any(x in needs for x in (ancestors + ((parent_type,)
|
|
173
|
+
if parent_type else ()))))
|
|
174
|
+
|
|
164
175
|
outcome = self.outcome.get(t)
|
|
165
|
-
if (not declared_child and not nested_as_family
|
|
176
|
+
if (not declared_child and not nested_as_family and not nested_ok
|
|
166
177
|
and (outcome == "BROKE_PAGE" or (outcome or "").startswith("COMMIT_5"))):
|
|
167
178
|
problems.append("%s is measured %s under a plain container, and %s is not its declared parent"
|
|
168
179
|
% (t, outcome, parent_type or "<root>"))
|
|
@@ -312,12 +323,13 @@ def refresh_node_ids(tree):
|
|
|
312
323
|
return json.loads(blob)
|
|
313
324
|
|
|
314
325
|
|
|
315
|
-
def flatten(node, parent_id, master_id, surface, force, ordering="a0",
|
|
326
|
+
def flatten(node, parent_id, master_id, surface, force, ordering="a0",
|
|
327
|
+
parent_type=None, out=None, ancestors=()):
|
|
316
328
|
"""Depth-first walk producing revision records, checking placement as it goes."""
|
|
317
329
|
out = out if out is not None else []
|
|
318
330
|
if node.get("component"):
|
|
319
331
|
node = dict(node, type="div") # placement is checked as a plain element
|
|
320
|
-
problems = surface.check(parent_type, node, force)
|
|
332
|
+
problems = surface.check(parent_type, node, force, ancestors)
|
|
321
333
|
if problems:
|
|
322
334
|
raise SystemExit("refusing to build:\n " + "\n ".join(problems))
|
|
323
335
|
|
|
@@ -360,7 +372,8 @@ def flatten(node, parent_id, master_id, surface, force, ordering="a0", parent_ty
|
|
|
360
372
|
if node.get("text") is not None:
|
|
361
373
|
children.insert(0, {"type": "wysiwyg-text", "data": {"text": node["text"]}})
|
|
362
374
|
for i, child in enumerate(children):
|
|
363
|
-
flatten(child, nid, master_id, surface, force, ordering_for(i),
|
|
375
|
+
flatten(child, nid, master_id, surface, force, ordering_for(i),
|
|
376
|
+
node["type"], out, ancestors + (node["type"],))
|
|
364
377
|
return out
|
|
365
378
|
|
|
366
379
|
|
|
@@ -69,7 +69,13 @@ def extract(plugin_root, out_dir):
|
|
|
69
69
|
sys.exit("no Mosaic/NodeTypes under %s" % plugin_root)
|
|
70
70
|
|
|
71
71
|
types = []
|
|
72
|
-
|
|
72
|
+
# `*MResourceFactory.php` as well as `*TypeFactory.php`: two node types -
|
|
73
|
+
# `fieldset-legend` and `fieldset-content` - are declared only by the former,
|
|
74
|
+
# so a walk of TypeFactory alone leaves them out of the catalog entirely. A
|
|
75
|
+
# page that needs them is then refused by the placement guard as an "unknown
|
|
76
|
+
# node type", and a reader of this table is told the fieldset has no inner
|
|
77
|
+
# structure, which is how a form came back with its fields dropped.
|
|
78
|
+
for path in sorted(walk(node_root, ("TypeFactory.php", "MResourceFactory.php"))):
|
|
73
79
|
src = read(path)
|
|
74
80
|
cls = RE_CLASS.search(src)
|
|
75
81
|
ctor = RE_CTOR.search(src)
|
|
@@ -57,7 +57,8 @@ def read(path):
|
|
|
57
57
|
def walk(root, suffix):
|
|
58
58
|
for dirpath, _dirs, files in os.walk(root):
|
|
59
59
|
for fn in sorted(files):
|
|
60
|
-
if fn.endswith(suffix)
|
|
60
|
+
if fn.endswith(suffix) if isinstance(suffix, str) else any(
|
|
61
|
+
fn.endswith(x) for x in suffix):
|
|
61
62
|
yield os.path.join(dirpath, fn)
|
|
62
63
|
|
|
63
64
|
|
|
@@ -68,7 +69,7 @@ def extract(plugin_root, out_dir):
|
|
|
68
69
|
|
|
69
70
|
# factory class name -> type slug, so instanceof targets can be named as types
|
|
70
71
|
class_to_type, sources = {}, {}
|
|
71
|
-
for path in walk(node_root, "TypeFactory.php"):
|
|
72
|
+
for path in walk(node_root, ("TypeFactory.php", "MResourceFactory.php")):
|
|
72
73
|
src = read(path)
|
|
73
74
|
cls = RE_CLASS.search(src)
|
|
74
75
|
if not cls:
|
|
@@ -95,6 +96,18 @@ def extract(plugin_root, out_dir):
|
|
|
95
96
|
body, defined_in = resolve(cls_name)
|
|
96
97
|
path, src, _parent = sources[cls_name]
|
|
97
98
|
nested = "yes" if "function canBeNestedChildFor" in src else ""
|
|
99
|
+
# `yes` alone says a runtime ancestry rule exists and not what it wants.
|
|
100
|
+
# These methods walk the parent chain looking for one MResource class -
|
|
101
|
+
# `radio-input` wants a `ChoiceElementMResource` above it - and that is
|
|
102
|
+
# the one fact a caller needs to know whether a placement is legal.
|
|
103
|
+
nested_under = []
|
|
104
|
+
if nested:
|
|
105
|
+
mbody = re.search(
|
|
106
|
+
r"function\s+canBeNestedChildFor\s*\([^)]*\)\s*:\s*bool\s*\{(.*?)\n \}",
|
|
107
|
+
src, re.S)
|
|
108
|
+
if mbody:
|
|
109
|
+
for cls in re.findall(r"instanceof\s+(\w+)ElementMResource\b", mbody.group(1)):
|
|
110
|
+
nested_under.append(cls)
|
|
98
111
|
|
|
99
112
|
if body is None:
|
|
100
113
|
rule, allowed = "none", [] # inherited default on the abstract base
|
|
@@ -121,6 +134,12 @@ def extract(plugin_root, out_dir):
|
|
|
121
134
|
"allowed_children": "|".join(allowed),
|
|
122
135
|
"nested_rule": nested,
|
|
123
136
|
"takes_children": "yes" if RE_TAKES_CHILDREN.search(src) else "",
|
|
137
|
+
# `class_to_type` is keyed by the FACTORY class, and what the
|
|
138
|
+
# method names is the MResource - `ChoiceElementMResource` is
|
|
139
|
+
# declared by `ChoiceElementTypeFactory`.
|
|
140
|
+
"nested_under": "|".join(sorted(set(
|
|
141
|
+
class_to_type.get(c + "ElementTypeFactory", "")
|
|
142
|
+
for c in nested_under) - {""})),
|
|
124
143
|
"declared_in": os.path.relpath(defined_in or path, plugin_root).replace(os.sep, "/"),
|
|
125
144
|
}
|
|
126
145
|
)
|
|
@@ -128,7 +147,8 @@ def extract(plugin_root, out_dir):
|
|
|
128
147
|
os.makedirs(out_dir, exist_ok=True)
|
|
129
148
|
with open(os.path.join(out_dir, "placement-rules.csv"), "w", newline="", encoding="utf-8") as fh:
|
|
130
149
|
w = csv.DictWriter(fh, fieldnames=["type", "rule", "allowed_children", "nested_rule",
|
|
131
|
-
"takes_children", "
|
|
150
|
+
"takes_children", "nested_under",
|
|
151
|
+
"declared_in"])
|
|
132
152
|
w.writeheader()
|
|
133
153
|
w.writerows(rows)
|
|
134
154
|
|
|
@@ -56,6 +56,18 @@ def main():
|
|
|
56
56
|
if not ok:
|
|
57
57
|
fails.append(name)
|
|
58
58
|
|
|
59
|
+
def skip(name, why):
|
|
60
|
+
"""A page that does not carry a component is not a page that broke it.
|
|
61
|
+
|
|
62
|
+
The same tool runs against every showcase page, so "absent" has to be a
|
|
63
|
+
third outcome - otherwise either the form page fails on tabs it never
|
|
64
|
+
had, or the checks get a flag per component and nobody runs them."""
|
|
65
|
+
rows.append([name, "SKIPPED", why])
|
|
66
|
+
print(" %-28s %-5s %s" % (name, "SKIP", why))
|
|
67
|
+
|
|
68
|
+
def present(el_id):
|
|
69
|
+
return p.evaluate("(id) => !!document.getElementById(id)", el_id)
|
|
70
|
+
|
|
59
71
|
def split(spec):
|
|
60
72
|
pat, n = spec.rsplit(":", 1)
|
|
61
73
|
tab, pane = pat.split("/")
|
|
@@ -71,64 +83,95 @@ def main():
|
|
|
71
83
|
p.wait_for_timeout(2200)
|
|
72
84
|
|
|
73
85
|
# ---- tabs
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
86
|
+
if not present(tab_pat % 0):
|
|
87
|
+
skip("TABS", "not on this page")
|
|
88
|
+
else:
|
|
89
|
+
shown = [i for i in range(n_tabs) if p.evaluate(VISIBLE, pane_pat % i)]
|
|
90
|
+
check("TABS/one_pane_at_rest", shown == [0],
|
|
91
|
+
"visible panes at rest: %s" % shown)
|
|
92
|
+
last = n_tabs - 1
|
|
93
|
+
p.click("#" + tab_pat % last)
|
|
94
|
+
p.wait_for_timeout(600)
|
|
95
|
+
shown = [i for i in range(n_tabs) if p.evaluate(VISIBLE, pane_pat % i)]
|
|
96
|
+
check("TABS/click_switches", shown == [last],
|
|
97
|
+
"clicked tab %d -> panes %s" % (last, shown))
|
|
98
|
+
active = p.evaluate("""() => {
|
|
99
|
+
const t = document.querySelector('.m-tab--active');
|
|
100
|
+
if (!t) return null;
|
|
101
|
+
const ps = [...t.querySelectorAll('p')].map(e => getComputedStyle(e).color);
|
|
102
|
+
return {bg: getComputedStyle(t).backgroundColor, labels: ps};
|
|
103
|
+
}""")
|
|
104
|
+
# the plugin darkens the active tab; a label that was not restyled
|
|
105
|
+
# with ___tab--active___descendants is then ink on ink
|
|
106
|
+
readable = bool(active) and active["bg"] != (active["labels"] or [""])[0]
|
|
107
|
+
check("TABS/active_label_readable", readable, str(active))
|
|
93
108
|
|
|
94
109
|
# ---- accordion
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
110
|
+
if not present(q_pat % 0):
|
|
111
|
+
skip("ACCORDION", "not on this page")
|
|
112
|
+
else:
|
|
113
|
+
open_now = [i for i in range(n_faq) if p.evaluate(VISIBLE, ans_pat % i)]
|
|
114
|
+
check("ACCORDION/shut_at_rest", open_now == [], "open: %s" % open_now)
|
|
115
|
+
p.click("#" + q_pat % 1)
|
|
116
|
+
p.wait_for_timeout(700)
|
|
117
|
+
open_now = [i for i in range(n_faq) if p.evaluate(VISIBLE, ans_pat % i)]
|
|
118
|
+
check("ACCORDION/click_opens_one", open_now == [1],
|
|
119
|
+
"clicked 1 -> open %s" % open_now)
|
|
120
|
+
ti = p.evaluate("(id) => document.getElementById(id).getAttribute('tabindex')",
|
|
121
|
+
q_pat % 1)
|
|
122
|
+
check("ACCORDION/keyboard_reachable", ti == "0",
|
|
123
|
+
"title tabindex=%r - the plugin puts it in the tab order" % ti)
|
|
106
124
|
|
|
107
125
|
# ---- form
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
126
|
+
if not present(a.form):
|
|
127
|
+
skip("FORM", "not on this page")
|
|
128
|
+
else:
|
|
129
|
+
fields = p.evaluate("""(id) => [...document.querySelectorAll(
|
|
130
|
+
'#' + id + ' input, #' + id + ' select, #' + id + ' textarea')].map(
|
|
131
|
+
e => ({name: e.getAttribute('name'), req: e.required,
|
|
132
|
+
tag: e.tagName.toLowerCase(), type: e.type}))""", a.form)
|
|
133
|
+
named = [f["name"] for f in fields if f["name"]]
|
|
134
|
+
honeypot = [n for n in named if n.startswith("mosaic_hp")]
|
|
135
|
+
real = [f for f in fields if f["name"] and not f["name"].startswith("mosaic_hp")]
|
|
136
|
+
kinds = sorted({f["type"] for f in real})
|
|
137
|
+
check("FORM/fields", len(real) >= 3,
|
|
138
|
+
"%d controls, types: %s" % (len(real), ", ".join(kinds)))
|
|
139
|
+
check("FORM/required", any(f["req"] for f in real),
|
|
140
|
+
"required is set on %d of them" % sum(1 for f in real if f["req"]))
|
|
141
|
+
check("FORM/honeypot", bool(honeypot),
|
|
142
|
+
"Mosaic adds its own spam trap: %s" % honeypot)
|
|
143
|
+
# a <select> whose options have no text renders as a blank dropdown:
|
|
144
|
+
# `select-option` is a LEAF and its label is the `text` PROPERTY
|
|
145
|
+
opts = p.evaluate("""(id) => [...document.querySelectorAll('#' + id + ' option')]
|
|
146
|
+
.map(o => o.textContent.trim())""", a.form)
|
|
147
|
+
if opts:
|
|
148
|
+
check("FORM/option_labels", all(opts),
|
|
149
|
+
"%d options, all labelled" % len(opts) if all(opts)
|
|
150
|
+
else "blank labels - `text` is a property, not a child")
|
|
151
|
+
else:
|
|
152
|
+
skip("FORM/option_labels", "no select on this form")
|
|
153
|
+
# the browser's own validation has to stop an empty required form
|
|
154
|
+
p.evaluate("(id) => document.getElementById(id).reset()", a.form)
|
|
155
|
+
invalid = p.evaluate("""(id) => [...document.getElementById(id)
|
|
156
|
+
.querySelectorAll(':invalid')].filter(e => e.name).map(e => e.name)""",
|
|
157
|
+
a.form)
|
|
158
|
+
check("FORM/required_blocks", bool(invalid),
|
|
159
|
+
"empty form reports invalid: %s" % invalid[:4])
|
|
121
160
|
|
|
122
161
|
# ---- map
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
162
|
+
if not present(a.map):
|
|
163
|
+
skip("MAP", "not on this page")
|
|
164
|
+
else:
|
|
165
|
+
src = p.evaluate("""(id) => { const f = document.querySelector('#' + id + ' iframe');
|
|
166
|
+
return f ? f.getAttribute('src') : null; }""", a.map)
|
|
167
|
+
m = re.search(r"marker=([-\d.]+),([-\d.]+)", src or "")
|
|
168
|
+
default_centre = bool(m) and m.group(1).startswith("40.75")
|
|
169
|
+
check("MAP/frame", bool(src), "renders an openstreetmap iframe")
|
|
170
|
+
check("MAP/coordinates_applied", bool(m) and not default_centre,
|
|
171
|
+
("marker=%s,%s" % m.groups()) if m and not default_centre
|
|
172
|
+
else "Times Square - the property name was wrong "
|
|
173
|
+
"(it is latitude/longitude, not lat/lon)")
|
|
174
|
+
|
|
132
175
|
b.close()
|
|
133
176
|
|
|
134
177
|
print("\n%d checks, %d FAIL" % (len(rows), len(fails)))
|