mosaic-headless 1.30.0 → 1.31.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.
Files changed (52) hide show
  1. package/README.ja.md +4 -2
  2. package/README.ko.md +4 -2
  3. package/README.md +5 -3
  4. package/README.zh-TW.md +4 -2
  5. package/SKILL.md +83 -39
  6. package/assets/templates/platforms/claude-ai.json +1 -1
  7. package/assets/templates/platforms/claude-code.json +1 -1
  8. package/assets/templates/platforms/codex-cli.json +1 -1
  9. package/assets/templates/platforms/copilot.json +1 -1
  10. package/assets/templates/platforms/gemini-cli.json +1 -1
  11. package/data/browser-verification/moksa.csv +3986 -0
  12. package/data/browser-verification/my-account.csv +148 -0
  13. package/data/component-wall-verification.csv +1 -1
  14. package/data/condition-comparators.csv +2 -2
  15. package/data/content-loop-verification.csv +4 -1
  16. package/data/data-class-hierarchy.csv +1 -0
  17. package/data/dialog-form-verification.csv +2 -2
  18. package/data/dialog-verification.csv +1 -1
  19. package/data/form-surface-verification.csv +3 -0
  20. package/data/interaction-types.csv +1 -0
  21. package/data/intro-verification.csv +16 -16
  22. package/data/mobile-audit.csv +22 -19
  23. package/data/navigation-verification.csv +16 -13
  24. package/data/node-properties.csv +10 -5
  25. package/data/node-types.csv +1 -0
  26. package/data/node-verification.csv +3 -0
  27. package/data/placement-rules.csv +1 -0
  28. package/data/pluggables.csv +16 -4
  29. package/data/popover-verification.csv +38 -0
  30. package/data/style-states.csv +1 -0
  31. package/data/token-verification.csv +2 -2
  32. package/package.json +3 -1
  33. package/references/dialog-and-triggers.md +199 -8
  34. package/references/dynamic-content.md +19 -0
  35. package/references/failure-modes.md +36 -4
  36. package/references/styling.md +9 -0
  37. package/references/upgrading.md +46 -1
  38. package/scripts/check-release.mjs +41 -11
  39. package/sites/_dialogs.py +18 -8
  40. package/sites/_moksa.py +69 -39
  41. package/sites/_popovers.py +570 -0
  42. package/tools/audit_mobile.py +1 -1
  43. package/tools/extract_default_children.py +1 -1
  44. package/tools/extract_node_types.py +1 -1
  45. package/tools/sweep_node_types.py +55 -6
  46. package/tools/verify_browser.py +20 -8
  47. package/tools/verify_conversion.py +6 -2
  48. package/tools/verify_dialog.py +3 -2
  49. package/tools/verify_dialog_lab.py +11 -11
  50. package/tools/verify_intro.py +5 -3
  51. package/tools/verify_popovers.py +348 -0
  52. package/data/browser-verification.csv +0 -4133
@@ -7,6 +7,20 @@
7
7
  sweep.json needs: base, version, cookie, nonce, themeID. --setup fills in masterID,
8
8
  templateID and parentNodeID and writes them back.
9
9
 
10
+ ## Which page it reads
11
+
12
+ The template --setup creates is assigned to `archive-post.php` - the post archive -
13
+ so the probes render wherever WordPress serves that archive. That used to be `/`,
14
+ and the sweep read `/`. Then the site got a static front page (`show_on_front=page`),
15
+ `/` became an ordinary page with its own template, and the sweep went on committing
16
+ probes into a master nothing rendered: every type, a plain `div` included, came
17
+ back COMMITTED. Nothing failed; it simply measured the wrong page.
18
+
19
+ So the page is named rather than assumed: `sweepPath` in the config, default
20
+ `?post_type=post`, which serves the post archive whatever the front page is set to.
21
+ Before a sweep, a plain `div` is committed and looked for on that page; if it does
22
+ not render, the run stops instead of recording a column of false COMMITTEDs.
23
+
10
24
  ## Why one at a time
11
25
 
12
26
  A batch sweep is worthless here. Mosaic accepts structurally impossible placements at
@@ -79,9 +93,14 @@ class Client:
79
93
  )
80
94
 
81
95
  def page(self, path=""):
82
- """Fetch a public page, cache-busted. `path` picks a page other than the home page."""
83
- req = urllib.request.Request("%s/%s?sweep=%s" % (self.base, path.strip("/") + "/" if path else "",
84
- uuid.uuid4().hex[:8]))
96
+ """Fetch a public page, cache-busted. `path` picks a page other than the home page:
97
+ a slug (`my-page`) or a query (`?post_type=post`)."""
98
+ bust = uuid.uuid4().hex[:8]
99
+ if path.startswith("?"):
100
+ url = "%s/%s&sweep=%s" % (self.base, path, bust)
101
+ else:
102
+ url = "%s/%s?sweep=%s" % (self.base, path.strip("/") + "/" if path else "", bust)
103
+ req = urllib.request.Request(url)
85
104
  try:
86
105
  with urllib.request.urlopen(req, timeout=120) as r:
87
106
  return r.read().decode("utf-8", "replace")
@@ -223,6 +242,34 @@ def unwrap(resp, what):
223
242
  return resp["response"]
224
243
 
225
244
 
245
+ def sweep_path(cfg):
246
+ """The public URL the sweep master renders on. See "Which page it reads"."""
247
+ return cfg.get("sweepPath", "?post_type=post")
248
+
249
+
250
+ def assert_probe_renders(client, cfg):
251
+ """Commit a plain div and require it on the sweep page, then remove it.
252
+
253
+ A div renders on any healthy page. If it does not show up, the page being read is
254
+ not the one the sweep master renders on, and every row this run would write is
255
+ a false COMMITTED - so stop before writing any."""
256
+ master_id = cfg["masterID"]
257
+ key = "node/master/%s" % master_id
258
+ doc = unwrap(client.get("masterDocumentInstance/%s" % master_id), "masterDocumentInstance")
259
+ baseline_ids = {n["ID"] for n in doc[key]}
260
+ attr, _nid, recs = probe_records("div", cfg["parentNodeID"], master_id)
261
+ client.commit("masterDocumentInstance/%s" % master_id, envelopes(doc), {key: recs})
262
+ try:
263
+ html = client.page(sweep_path(cfg))
264
+ if ('id="%s"' % attr) not in html:
265
+ sys.exit("a plain div committed to the sweep master does not render on /%s - "
266
+ "that page is not the one the master renders on, so every row would "
267
+ "read COMMITTED. Set sweepPath in the config to a URL served by the "
268
+ "post archive (the default is ?post_type=post)." % sweep_path(cfg))
269
+ finally:
270
+ delete_subtree(client, master_id, baseline_ids, cfg["parentNodeID"])
271
+
272
+
226
273
  def setup(client, cfg, config_path):
227
274
  """Create the master (which heals into a default node tree) and the template."""
228
275
  tei = unwrap(client.get("adminMasterEditorInstance"), "adminMasterEditorInstance")
@@ -282,7 +329,7 @@ def setup(client, cfg, config_path):
282
329
  json.dump(cfg, fh, indent=1)
283
330
  print("masterID=%s\ntemplateID=%s\nparentNodeID=%s" % (master_id, template_id, parent))
284
331
 
285
- html = client.page()
332
+ html = client.page(sweep_path(cfg))
286
333
  print("baseline page: %d bytes%s" % (len(html), "" if len(html) >= MIN_HEALTHY_BYTES else " <-- ALREADY BROKEN"))
287
334
 
288
335
 
@@ -347,12 +394,14 @@ def delete_subtree(client, master_id, keep_ids, parent_id=None):
347
394
 
348
395
 
349
396
  def sweep(client, cfg, types, out_path):
397
+ assert_probe_renders(client, cfg)
350
398
  master_id = cfg["masterID"]
351
399
  doc = unwrap(client.get("masterDocumentInstance/%s" % master_id), "masterDocumentInstance")
352
400
  key = "node/master/%s" % master_id
353
401
  baseline_ids = {n["ID"] for n in doc[key]}
354
402
 
355
- baseline_html = client.page()
403
+ path = sweep_path(cfg)
404
+ baseline_html = client.page(path)
356
405
  if len(baseline_html) < MIN_HEALTHY_BYTES:
357
406
  sys.exit("baseline page is already broken (%d bytes); reset the theme first" % len(baseline_html))
358
407
  print("baseline: %d bytes, %d nodes\n" % (len(baseline_html), len(baseline_ids)))
@@ -373,7 +422,7 @@ def sweep(client, cfg, types, out_path):
373
422
  err = "PHP fatal / gateway error during commit"
374
423
  tag, classes, echoed, size = "", "", "", ""
375
424
  else:
376
- html = client.page()
425
+ html = client.page(path)
377
426
  size = len(html)
378
427
  if size < MIN_HEALTHY_BYTES:
379
428
  outcome, tag, classes, echoed = "BROKE_PAGE", "", "", ""
@@ -213,7 +213,7 @@ def tidy(text):
213
213
  report every hairline on the page as overridden."""
214
214
  t = re.sub(r"\s*,\s*", ",", (text or "").strip().lower())
215
215
  t = re.sub(r"\s+", " ", t)
216
- return re.sub(r"(?<![\d.])\.(\d)", r"0.", t)
216
+ return re.sub(r"(?<![\d.])\.(\d)", r"0.\1", t)
217
217
 
218
218
 
219
219
  # The comparison knows properties by their Mosaic key, but customDeclarations hands it raw
@@ -609,7 +609,8 @@ def main():
609
609
  ap.add_argument("--config",
610
610
  help="JSON with base/cookie/nonce; omit it and the MOSAIC_SITE_URL / MOSAIC_REST_COOKIE / MOSAIC_REST_NONCE environment variables supply them instead")
611
611
  ap.add_argument("--site", required=True)
612
- ap.add_argument("--csv", help="the computed-value table")
612
+ ap.add_argument("--csv", help="a DIRECTORY: the computed-value table is "
613
+ "written one file per page, <dir>/<slug>.csv")
613
614
  ap.add_argument("--audit", help="the design-audit findings")
614
615
  ap.add_argument("--ack", default=os.path.join(
615
616
  os.path.dirname(os.path.abspath(__file__)), "..", "data",
@@ -754,13 +755,24 @@ def main():
754
755
  # count in the release gate - should not have to find the blank line between them.
755
756
  # The audit file is written even when it is empty: a header with no rows says
756
757
  # "this ran and found nothing", where a missing file says nothing at all.
758
+ #
759
+ # The computed-value table is one file per page, named by its slug, with no url
760
+ # column: a single file carried the site's address 4,000 times over and passed
761
+ # the plugin directory's 256 KiB per-file limit. The slug is the filename, and
762
+ # the address is whatever --config pointed at.
757
763
  if a.csv:
758
- with open(a.csv, "w", newline="", encoding="utf-8") as fh:
759
- w = csv.writer(fh)
760
- w.writerow(["url", "breakpoint", "attrID", "property", "declared",
761
- "computed", "status"])
762
- w.writerows(rows)
763
- print("\nwrote %s (%d rows)" % (a.csv, len(rows)))
764
+ os.makedirs(a.csv, exist_ok=True)
765
+ per_page = {}
766
+ for r in rows:
767
+ per_page.setdefault(r[0].rstrip("/").rsplit("/", 1)[-1], []).append(r[1:])
768
+ for slug, page_rows in per_page.items():
769
+ out = os.path.join(a.csv, slug + ".csv")
770
+ with open(out, "w", newline="", encoding="utf-8") as fh:
771
+ w = csv.writer(fh)
772
+ w.writerow(["breakpoint", "attrID", "property", "declared",
773
+ "computed", "status"])
774
+ w.writerows(page_rows)
775
+ print("\nwrote %s (%d rows)" % (out, len(page_rows)))
764
776
  if a.audit:
765
777
  with open(a.audit, "w", newline="", encoding="utf-8") as fh:
766
778
  w = csv.writer(fh)
@@ -29,6 +29,7 @@ import argparse
29
29
  import csv
30
30
  import html as htmllib
31
31
  import json
32
+ import os
32
33
  import re
33
34
  import sys
34
35
  import time
@@ -129,7 +130,7 @@ def main():
129
130
  ap.add_argument("--report", help="the CSV from_elementor.py wrote")
130
131
  ap.add_argument("--csv")
131
132
  ap.add_argument("--width", type=int, default=1440)
132
- ap.add_argument("--browser", help="browser-verification CSV from verify_browser.py")
133
+ ap.add_argument("--browser", help="browser-verification CSV, or the per-page directory, from verify_browser.py")
133
134
  ap.add_argument("--audit", help="design-audit CSV from verify_browser.py")
134
135
  ap.add_argument("--rwd", help="rwd-verification CSV from verify_rwd.py")
135
136
  ap.add_argument("--prefix", default="el",
@@ -230,7 +231,10 @@ def main():
230
231
  "own media query" % len(rr) if not bad else
231
232
  "%d of %d not verified" % (len(bad), len(rr)))
232
233
  if a.browser:
233
- br = list(csv.DictReader(open(a.browser, encoding="utf-8")))
234
+ # a file, or the per-page directory verify_browser.py writes
235
+ files = ([os.path.join(a.browser, f) for f in sorted(os.listdir(a.browser))
236
+ if f.endswith(".csv")] if os.path.isdir(a.browser) else [a.browser])
237
+ br = [r for f in files for r in csv.DictReader(open(f, encoding="utf-8"))]
234
238
  ok = sum(1 for r in br if r.get("status") == "ok")
235
239
  over = [r for r in br if r.get("status") == "OVERRIDDEN"]
236
240
  check("COMPUTED", bool(br) and not over,
@@ -68,14 +68,15 @@ DIALOG_STATE = """id => {
68
68
  const el = document.getElementById(id);
69
69
  if (!el) return null;
70
70
  const cs = getComputedStyle(el);
71
- return {tag: el.tagName.toLowerCase(), open: !!el.open,
71
+ return {tag: el.tagName.toLowerCase(), open: !!(el.open || el.matches(':popover-open') || el.getAttribute('data-mosaic-dialog-state') === 'open'),
72
72
  display: cs.display, visible: cs.display !== 'none',
73
73
  role: el.getAttribute('role'), ariaModal: el.getAttribute('aria-modal'),
74
74
  ariaLabel: el.getAttribute('aria-label'),
75
75
  closedby: el.getAttribute('data-mosaic-modal-closedby'),
76
76
  hasOverlay: !!el.querySelector('.m-modal-overlay'),
77
77
  hasWindow: !!el.querySelector('.m-modal-window'),
78
- inTopLayer: typeof el.matches === 'function' && el.matches(':modal')};
78
+ inTopLayer: typeof el.matches === 'function'
79
+ && (el.matches(':modal') || el.matches(':popover-open'))};
79
80
  }"""
80
81
 
81
82
  # An exit-intent trigger listens for the pointer leaving through the top of the
@@ -38,10 +38,10 @@ def row(check, result, detail=""):
38
38
  print("%-34s %-5s %s" % (check, result, detail))
39
39
 
40
40
 
41
- OPEN = "(id) => { const e = document.getElementById(id); return e ? !!e.open : null; }"
41
+ OPEN = "(id) => { const e = document.getElementById(id); return e ? !!(e.open || e.matches(':popover-open') || e.getAttribute('data-mosaic-dialog-state') === 'open') : null; }"
42
42
  BOX = """(id) => {
43
43
  const e = document.getElementById(id);
44
- if (!e || !e.open) return null;
44
+ if (!e || !(e.open || e.matches(':popover-open') || e.getAttribute('data-mosaic-dialog-state') === 'open')) return null;
45
45
  const w = e.querySelector('.m-modal-window');
46
46
  const r = w.getBoundingClientRect();
47
47
  const hr = e.getBoundingClientRect();
@@ -59,8 +59,8 @@ BOX = """(id) => {
59
59
 
60
60
  def shut_all(p):
61
61
  p.evaluate("""() => document.querySelectorAll('dialog.m-modal').forEach(d => {
62
- if (d.open) { try { d.mosaicDialog ? d.mosaicDialog.close() : d.close(); }
63
- catch(e) { d.close(); } } })""")
62
+ if ((d.open || d.matches(':popover-open') || d.getAttribute('data-mosaic-dialog-state') === 'open')) { try { (d.mosaicDialog ? (d.mosaicDialog.requestClose || d.mosaicDialog.close).call(d.mosaicDialog) : d.matches(':popover-open') ? d.hidePopover() : d.close()); }
63
+ catch(e) {} } })""")
64
64
  p.wait_for_timeout(250)
65
65
 
66
66
 
@@ -122,10 +122,10 @@ def main():
122
122
  # version of this verifier passed 36/36 on a page where all ten were
123
123
  # permanently on screen.
124
124
  p.evaluate("""() => document.querySelectorAll('dialog.m-modal')
125
- .forEach(d => { if (d.open) { try { d.close(); } catch (e) {} } })""")
125
+ .forEach(d => { if ((d.open || d.matches(':popover-open') || d.getAttribute('data-mosaic-dialog-state') === 'open')) { try { (d.mosaicDialog ? (d.mosaicDialog.requestClose || d.mosaicDialog.close).call(d.mosaicDialog) : d.matches(':popover-open') ? d.hidePopover() : d.close()); } catch (e) {} } })""")
126
126
  p.wait_for_timeout(400)
127
127
  shown = p.evaluate("""() => [...document.querySelectorAll('dialog.m-modal')]
128
- .filter(d => !d.open)
128
+ .filter(d => !(d.open || d.matches(':popover-open') || d.getAttribute('data-mosaic-dialog-state') === 'open'))
129
129
  .filter(d => { const r = d.getBoundingClientRect();
130
130
  return r.width > 0 && r.height > 0; })
131
131
  .map(d => d.id + ' (display:' + getComputedStyle(d).display + ')')""")
@@ -241,7 +241,7 @@ def main():
241
241
  p2.goto(URL + "?v=%s%d" % (order[0], i), wait_until="load")
242
242
  p2.wait_for_timeout(2200)
243
243
  p2.evaluate("""() => document.querySelectorAll('dialog.m-modal')
244
- .forEach(d => { if (d.open) d.close(); })""")
244
+ .forEach(d => { if ((d.open || d.matches(':popover-open') || d.getAttribute('data-mosaic-dialog-state') === 'open')) (d.mosaicDialog ? (d.mosaicDialog.requestClose || d.mosaicDialog.close).call(d.mosaicDialog) : d.matches(':popover-open') ? d.hidePopover() : d.close()); })""")
245
245
  steps = []
246
246
  for which in order:
247
247
  p2.click("#dl-open-%s" % which)
@@ -249,7 +249,7 @@ def main():
249
249
  opened = [k for k in ("aba", "abb") if p2.evaluate(OPEN, "dl-" + k)]
250
250
  steps.append("%s>%s" % (which, "+".join(opened) or "none"))
251
251
  p2.evaluate("""() => document.querySelectorAll('dialog.m-modal')
252
- .forEach(d => { if (d.open) d.close(); })""")
252
+ .forEach(d => { if ((d.open || d.matches(':popover-open') || d.getAttribute('data-mosaic-dialog-state') === 'open')) (d.mosaicDialog ? (d.mosaicDialog.requestClose || d.mosaicDialog.close).call(d.mosaicDialog) : d.matches(':popover-open') ? d.hidePopover() : d.close()); })""")
253
253
  p2.wait_for_timeout(200)
254
254
  seen[",".join(steps)] += 1
255
255
  if i == 0:
@@ -289,7 +289,7 @@ def main():
289
289
  # document underneath measures clean.
290
290
  FIT = """(id) => {
291
291
  const e = document.getElementById(id);
292
- if (!e || !e.open) return null;
292
+ if (!e || !(e.open || e.matches(':popover-open') || e.getAttribute('data-mosaic-dialog-state') === 'open')) return null;
293
293
  const w = e.querySelector('.m-modal-window');
294
294
  const r = w.getBoundingClientRect(), h = e.getBoundingClientRect();
295
295
  let over = 0, worst = null;
@@ -309,7 +309,7 @@ def main():
309
309
  pm.goto(URL, wait_until="load")
310
310
  pm.wait_for_timeout(2000)
311
311
  pm.evaluate("() => document.querySelectorAll('dialog')"
312
- ".forEach(d => d.open && d.close())")
312
+ ".forEach(d => (d.open || d.matches(':popover-open') || d.getAttribute('data-mosaic-dialog-state') === 'open') && (d.mosaicDialog ? (d.mosaicDialog.requestClose || d.mosaicDialog.close).call(d.mosaicDialog) : d.matches(':popover-open') ? d.hidePopover() : d.close()))")
313
313
  for key in ("centre", "sheet", "drawer", "takeover", "gate", "aba"):
314
314
  try:
315
315
  pm.click("#dl-open-%s" % key, timeout=5000)
@@ -328,7 +328,7 @@ def main():
328
328
  % (f["scrollW"], f["clientW"], f["over"],
329
329
  (" <%s>" % f["worst"]) if f["over"] > 1 else "", f["doc"]))
330
330
  pm.evaluate("() => document.querySelectorAll('dialog')"
331
- ".forEach(d => d.open && d.close())")
331
+ ".forEach(d => (d.open || d.matches(':popover-open') || d.getAttribute('data-mosaic-dialog-state') === 'open') && (d.mosaicDialog ? (d.mosaicDialog.requestClose || d.mosaicDialog.close).call(d.mosaicDialog) : d.matches(':popover-open') ? d.hidePopover() : d.close()))")
332
332
  pm.wait_for_timeout(200)
333
333
  cm.close()
334
334
 
@@ -53,8 +53,8 @@ import time
53
53
 
54
54
  # When to read. Dense through the sequence, then two well past its end - the last
55
55
  # two are what turn "it looked right" into "it finished".
56
- SAMPLES_MS = [120, 700, 1400, 2200, 3000, 3800, 4600, 5400, 6200, 7000,
57
- 7800, 8300, 8900, 11000, 13000]
56
+ SAMPLES_MS = [120, 400, 700, 1000, 1300, 1600, 1900, 2200, 2500, 2800,
57
+ 3100, 3400, 3800, 5000, 7000]
58
58
 
59
59
  # What to read at each sample. Anything whose id starts with the veil prefix is
60
60
  # treated as part of the intro; everything else is content that must end up visible.
@@ -258,7 +258,9 @@ def main():
258
258
  help="ids under this prefix are the intro, not the content")
259
259
  ap.add_argument("--min-ambient", type=int, default=3,
260
260
  help="how many elements must still be moving once settled")
261
- ap.add_argument("--deadline-ms", type=int, default=8900,
261
+ # 3400, not the 8900 it used to be: the entrance was compressed to a 2.15s veil
262
+ # plus its .9s exit, because nine seconds before the headline was the defect.
263
+ ap.add_argument("--deadline-ms", type=int, default=3400,
262
264
  help="by this point the intro must be over")
263
265
  ap.add_argument("--csv")
264
266
  ap.add_argument("--frames")