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
@@ -0,0 +1,348 @@
1
+ #!/usr/bin/env python3
2
+ """Drive every popover on the popover lab and record what actually happened.
3
+
4
+ python tools/verify_popovers.py --url https://site/popovers/ --csv data/popover-verification.csv
5
+
6
+ Mosaic 1.0.10's popover is a <div popover="manual"> the controller opens with
7
+ showPopover(), so none of what matters is in the markup: whether it opened, where
8
+ it landed relative to its anchor, whether it blocks the page, whether `closedby`
9
+ does what it says, whether `afterClose` chains to the next one, whether
10
+ `keepInView` flipped it and the flipped style state applied. Each of those is
11
+ measured here in Chromium.
12
+
13
+ The open state is read the way the runtime defines it - `:popover-open` - not from
14
+ an attribute: a popover never carries `open`.
15
+
16
+ Expected ids are the lab's (`sites/_popovers.py`); a missing one is recorded as
17
+ SKIPPED rather than failed, so the tool can run against a page that carries only
18
+ some of them.
19
+ """
20
+ import argparse
21
+ import csv
22
+ import sys
23
+ import time
24
+
25
+ from playwright.sync_api import sync_playwright
26
+
27
+ ROWS = []
28
+
29
+
30
+ def row(check, result, detail=""):
31
+ ROWS.append([check, result, detail])
32
+ print("%-30s %-7s %s" % (check, result, detail))
33
+
34
+
35
+ IS_OPEN = "(id) => { const e = document.getElementById(id); return e ? e.matches(':popover-open') : null; }"
36
+ RECT = """(id) => { const e = document.getElementById(id); if (!e) return null;
37
+ const r = e.getBoundingClientRect();
38
+ return {x: r.x, y: r.y, w: r.width, h: r.height, r: r.right, b: r.bottom,
39
+ vw: document.documentElement.clientWidth, vh: innerHeight}; }"""
40
+ ATTRS = """(id) => { const e = document.getElementById(id); if (!e) return null;
41
+ const o = {}; for (const a of e.attributes) o[a.name] = a.value;
42
+ o.__tag = e.tagName.toLowerCase(); o.__display = getComputedStyle(e).display;
43
+ o.__bg = getComputedStyle(e).backgroundColor; return o; }"""
44
+ CLOSE_ALL = """() => document.querySelectorAll('.m-popover').forEach(p => {
45
+ if (p.matches(':popover-open')) {
46
+ const c = p.mosaicDialog;
47
+ c && c.requestClose ? c.requestClose() : p.hidePopover(); } })"""
48
+ OPEN_IDS = """() => [...document.querySelectorAll('.m-popover')]
49
+ .filter(p => p.matches(':popover-open')).map(p => p.id)"""
50
+
51
+ CELLS = [(v, h) for v in ("top", "center", "bottom") for h in ("left", "center", "right")]
52
+ EDGE = 16 # the lab insets screen popovers 12px from the viewport edge
53
+
54
+
55
+ def main():
56
+ ap = argparse.ArgumentParser()
57
+ ap.add_argument("--url", required=True)
58
+ ap.add_argument("--csv")
59
+ a = ap.parse_args()
60
+ url = a.url + ("&" if "?" in a.url else "?") + "_v=%d" % int(time.time() * 1000)
61
+
62
+ with sync_playwright() as pw:
63
+ b = pw.chromium.launch()
64
+ ctx = b.new_context(viewport={"width": 1280, "height": 900})
65
+ p = ctx.new_page()
66
+ errors = []
67
+ p.on("pageerror", lambda e: errors.append(str(e)))
68
+ p.goto(url, wait_until="load")
69
+ p.wait_for_timeout(2000)
70
+
71
+ present = p.evaluate("() => [...document.querySelectorAll('.m-popover')].map(e => e.id)")
72
+ row("FOUND", "PASS" if present else "FAIL", "%d popovers on the page" % len(present))
73
+ if not present:
74
+ return finish(a, b)
75
+
76
+ # ---- the host
77
+ bad = []
78
+ for pid in present:
79
+ at = p.evaluate(ATTRS, pid)
80
+ if at["__tag"] != "div" or at.get("popover") != "manual" or "open" in at:
81
+ bad.append("%s <%s popover=%s open=%s>" % (pid, at["__tag"], at.get("popover"),
82
+ "open" in at))
83
+ row("HOST", "FAIL" if bad else "PASS",
84
+ "; ".join(bad) or "every one is <div popover=manual>, none carries `open`")
85
+
86
+ # ---- the notice opens itself on load, and does not block the page
87
+ if "pp-notice" in present:
88
+ opened = p.evaluate(IS_OPEN, "pp-notice")
89
+ row("NOTICE/pageLoad", "PASS" if opened else "FAIL",
90
+ "open after load" if opened else "not open after load")
91
+ at = p.evaluate(ATTRS, "pp-notice")
92
+ row("NOTICE/aria", "PASS" if at.get("role") == "status" and at.get("aria-live") == "polite"
93
+ and "aria-modal" not in at else "FAIL",
94
+ "role=%s aria-live=%s aria-modal=%s" % (at.get("role"), at.get("aria-live"),
95
+ at.get("aria-modal")))
96
+ if opened:
97
+ # what is under the middle of the viewport: the page, not a veil
98
+ top = p.evaluate("""() => { const e = document.elementFromPoint(innerWidth/2, innerHeight/2);
99
+ return e ? (e.closest('.m-popover') ? 'popover' : 'page') : null; }""")
100
+ y0 = p.evaluate("() => scrollY")
101
+ p.mouse.move(640, 450)
102
+ p.mouse.wheel(0, 600)
103
+ p.wait_for_timeout(500)
104
+ y1 = p.evaluate("() => scrollY")
105
+ row("NOTICE/non_blocking", "PASS" if top == "page" and y1 > y0 else "FAIL",
106
+ "centre hit-tests to the %s; wheel scrolled %d -> %d" % (top, y0, y1))
107
+ # a click elsewhere works while it is open, and does not close it
108
+ if "pp-menu" in present:
109
+ p.click("#pp-open-menu")
110
+ p.wait_for_timeout(400)
111
+ both = p.evaluate(OPEN_IDS)
112
+ row("NOTICE/page_usable", "PASS" if "pp-menu" in both and "pp-notice" in both
113
+ else "FAIL", "open while the notice shows: %s" % both)
114
+ p.keyboard.press("Escape")
115
+ p.wait_for_timeout(300)
116
+ still = p.evaluate(IS_OPEN, "pp-notice")
117
+ row("NOTICE/closedby_nothing", "PASS" if still else "FAIL",
118
+ "Esc left it open" if still else "Esc closed a closedby=nothing popover")
119
+ p.click("#pp-close-notice")
120
+ p.wait_for_timeout(400)
121
+ gone = not p.evaluate(IS_OPEN, "pp-notice")
122
+ row("NOTICE/close_button", "PASS" if gone else "FAIL",
123
+ "popoverClose closed it" if gone else "still open")
124
+ else:
125
+ row("NOTICE", "SKIPPED", "not on this page")
126
+ p.evaluate(CLOSE_ALL)
127
+ p.wait_for_timeout(300)
128
+
129
+ # ---- closed at rest: nothing else is showing, and closed means 0x0
130
+ shown = p.evaluate("""() => [...document.querySelectorAll('.m-popover')]
131
+ .filter(e => !e.matches(':popover-open'))
132
+ .filter(e => { const r = e.getBoundingClientRect(); return r.width || r.height; })
133
+ .map(e => e.id)""")
134
+ row("CLOSED_IS_HIDDEN", "FAIL" if shown else "PASS",
135
+ ("still rendered: %s" % shown) if shown else "every closed popover measures 0x0")
136
+
137
+ # ---- the anchored menu: below its trigger, start-aligned, toggles, Esc
138
+ if "pp-menu" in present:
139
+ p.click("#pp-open-menu")
140
+ p.wait_for_timeout(400)
141
+ m, t = p.evaluate(RECT, "pp-menu"), p.evaluate(RECT, "pp-open-menu")
142
+ # block-end start, or block-start once keepInView flipped it for lack of
143
+ # room below - start-aligned with the trigger either way
144
+ flipped = "data-mosaic-popover-flipped" in p.evaluate(ATTRS, "pp-menu")
145
+ beside = (m["b"] <= t["y"] + 1) if flipped else (m["y"] >= t["b"] - 1)
146
+ ok = m and m["w"] and beside and abs(m["x"] - t["x"]) <= 2
147
+ row("MENU/placement", "PASS" if ok else "FAIL",
148
+ "%s: menu %d..%d high, trigger %d..%d, left edges %d / %d"
149
+ % ("flipped above" if flipped else "below", m["y"], m["b"], t["y"], t["b"],
150
+ m["x"], t["x"]) if m else "did not open")
151
+ p.click("#pp-open-menu")
152
+ p.wait_for_timeout(400)
153
+ row("MENU/toggle", "PASS" if not p.evaluate(IS_OPEN, "pp-menu") else "FAIL",
154
+ "the same button closed it")
155
+ p.click("#pp-open-menu")
156
+ p.wait_for_timeout(300)
157
+ p.keyboard.press("Escape")
158
+ p.wait_for_timeout(300)
159
+ row("MENU/esc", "PASS" if not p.evaluate(IS_OPEN, "pp-menu") else "FAIL",
160
+ "closedby=everything: Esc closed it")
161
+ p.evaluate(CLOSE_ALL)
162
+
163
+ # ---- the info card: inline-end, Esc does nothing, a click outside closes
164
+ if "pp-info" in present:
165
+ p.click("#pp-open-info")
166
+ p.wait_for_timeout(400)
167
+ m, t = p.evaluate(RECT, "pp-info"), p.evaluate(RECT, "pp-open-info")
168
+ flipped = "data-mosaic-popover-flipped" in p.evaluate(ATTRS, "pp-info")
169
+ # inline-end, or inline-start when keepInView flipped it - either way beside
170
+ # the trigger and vertically centred on it
171
+ beside = (m["r"] <= t["x"] + 1) if flipped else (m["x"] >= t["r"] - 1)
172
+ ok = m and beside and abs((m["y"] + m["h"] / 2) - (t["y"] + t["h"] / 2)) <= 3
173
+ row("INFO/placement", "PASS" if ok else "FAIL",
174
+ "%s: card %d..%d, trigger %d..%d; centres %d / %d"
175
+ % ("flipped to inline-start" if flipped else "inline-end", m["x"], m["r"],
176
+ t["x"], t["r"], m["y"] + m["h"] / 2, t["y"] + t["h"] / 2) if m else "did not open")
177
+ p.keyboard.press("Escape")
178
+ p.wait_for_timeout(300)
179
+ row("INFO/esc_ignored", "PASS" if p.evaluate(IS_OPEN, "pp-info") else "FAIL",
180
+ "closedby=clickOutside: Esc left it open")
181
+ p.mouse.click(20, 450)
182
+ p.wait_for_timeout(400)
183
+ row("INFO/click_outside", "PASS" if not p.evaluate(IS_OPEN, "pp-info") else "FAIL",
184
+ "a click outside closed it")
185
+ p.evaluate(CLOSE_ALL)
186
+
187
+ # ---- anchored to some other element than its trigger
188
+ if "pp-pointer" in present:
189
+ p.click("#pp-open-pointer")
190
+ p.wait_for_timeout(500)
191
+ m, card = p.evaluate(RECT, "pp-pointer"), p.evaluate(RECT, "pp-work-1")
192
+ ok = m and m["b"] <= card["y"] + 1 and abs((m["x"] + m["w"] / 2) - (card["x"] + card["w"] / 2)) <= 3
193
+ row("POINTER/anchor", "PASS" if ok else "FAIL",
194
+ "popover bottom %d vs card top %d; centres %d / %d"
195
+ % (m["b"], card["y"], m["x"] + m["w"] / 2, card["x"] + card["w"] / 2) if m else "did not open")
196
+ p.evaluate(CLOSE_ALL)
197
+
198
+ # ---- the screen grid: each cell where its name says
199
+ for v, h in CELLS:
200
+ pid = "pp-cell-%s-%s" % (v, h)
201
+ if pid not in present:
202
+ continue
203
+ p.click("#pp-open-cell-%s-%s" % (v, h))
204
+ p.wait_for_timeout(300)
205
+ r = p.evaluate(RECT, pid)
206
+ if not r or not r["w"]:
207
+ row("GRID/%s-%s" % (v, h), "FAIL", "did not open")
208
+ continue
209
+ # docked to its cell: within EDGE of the named edge, or centred on the axis
210
+ cx, cy = r["x"] + r["w"] / 2, r["y"] + r["h"] / 2
211
+ want_x = {"left": r["x"] <= EDGE, "right": r["r"] >= r["vw"] - EDGE,
212
+ "center": abs(cx - r["vw"] / 2) <= 2}[h]
213
+ want_y = {"top": r["y"] <= EDGE, "bottom": r["b"] >= r["vh"] - EDGE,
214
+ "center": abs(cy - r["vh"] / 2) <= 2}[v]
215
+ row("GRID/%s-%s" % (v, h), "PASS" if want_x and want_y else "FAIL",
216
+ "box %d,%d %dx%d in %dx%d" % (r["x"], r["y"], r["w"], r["h"], r["vw"], r["vh"]))
217
+ p.evaluate(CLOSE_ALL)
218
+ p.wait_for_timeout(200)
219
+
220
+ # ---- the tour: afterClose advances it, "end the tour" suspends it, Esc is ignored
221
+ if "pp-tour-brief" in present:
222
+ p.click("#pp-open-tour")
223
+ p.wait_for_timeout(500)
224
+ seen, under = [p.evaluate(OPEN_IDS)], []
225
+ for key in ("brief", "quote", "launch"):
226
+ m, box_ = p.evaluate(RECT, "pp-tour-" + key), p.evaluate(RECT, "pp-tour-box-" + key)
227
+ if m and m["w"]:
228
+ under.append(m["y"] >= box_["b"] - 1 or m["b"] <= box_["y"] + 1)
229
+ p.click("#pp-tour-next-" + key)
230
+ p.wait_for_timeout(800)
231
+ seen.append(p.evaluate(OPEN_IDS))
232
+ want = [["pp-tour-brief"], ["pp-tour-quote"], ["pp-tour-launch"], ["pp-tour-done"]]
233
+ row("TOUR/steps", "PASS" if seen == want else "FAIL",
234
+ " > ".join("+".join(s) or "none" for s in seen))
235
+ row("TOUR/anchored", "PASS" if under and all(under) else "FAIL",
236
+ "%d of 3 steps sit beside the box they explain" % sum(under))
237
+ p.evaluate(CLOSE_ALL)
238
+ p.wait_for_timeout(400)
239
+ # "end the tour" on a middle step suspends that step's afterClose before
240
+ # closing it, so nothing follows; Esc is not a way out (it would fire
241
+ # afterClose too), so a step ignores it
242
+ p.click("#pp-open-tour")
243
+ p.wait_for_timeout(500)
244
+ p.click("#pp-tour-next-brief")
245
+ p.wait_for_timeout(800)
246
+ p.keyboard.press("Escape")
247
+ p.wait_for_timeout(500)
248
+ esc = p.evaluate(OPEN_IDS)
249
+ row("TOUR/esc_ignored", "PASS" if esc == ["pp-tour-quote"] else "FAIL",
250
+ "open after Esc on step 2: %s" % ("+".join(esc) or "none"))
251
+ p.click("#pp-tour-quit-quote")
252
+ p.wait_for_timeout(800)
253
+ after = p.evaluate(OPEN_IDS)
254
+ row("TOUR/quit_ends", "PASS" if not after else "FAIL",
255
+ "open after 結束導覽 on step 2: %s" % ("+".join(after) or "none"))
256
+ # and the suspended step is resumed by the next start
257
+ p.click("#pp-open-tour")
258
+ p.wait_for_timeout(500)
259
+ p.click("#pp-tour-next-brief")
260
+ p.wait_for_timeout(800)
261
+ p.click("#pp-tour-next-quote")
262
+ p.wait_for_timeout(800)
263
+ again = p.evaluate(OPEN_IDS)
264
+ row("TOUR/restart_resumes", "PASS" if again == ["pp-tour-launch"] else "FAIL",
265
+ "restarted, two steps on: %s" % ("+".join(again) or "none"))
266
+ p.evaluate(CLOSE_ALL)
267
+ p.wait_for_timeout(1000)
268
+ p.evaluate(CLOSE_ALL)
269
+
270
+ # ---- keepInView: flips, and the flipped state styles it
271
+ if "pp-snap-on" in present:
272
+ p.click("#pp-open-snap-on")
273
+ p.wait_for_timeout(500)
274
+ at, m, t = (p.evaluate(ATTRS, "pp-snap-on"), p.evaluate(RECT, "pp-snap-on"),
275
+ p.evaluate(RECT, "pp-open-snap-on"))
276
+ flipped = "data-mosaic-popover-flipped" in at
277
+ row("SNAP/flips", "PASS" if flipped and m["r"] <= t["x"] + 1 else "FAIL",
278
+ "flipped=%s; popover right %d vs trigger left %d" % (flipped, m["r"], t["x"]))
279
+ row("SNAP/flipped_state", "PASS" if at["__bg"] in ("rgb(22, 24, 28)",) else "FAIL",
280
+ "background while flipped: %s" % at["__bg"])
281
+ p.evaluate(CLOSE_ALL)
282
+ p.wait_for_timeout(300)
283
+ p.click("#pp-open-snap-off")
284
+ p.wait_for_timeout(500)
285
+ at, m = p.evaluate(ATTRS, "pp-snap-off"), p.evaluate(RECT, "pp-snap-off")
286
+ t = p.evaluate(RECT, "pp-open-snap-off")
287
+ # Measured, and not what the name suggests: without keepInView the popover
288
+ # does not overflow. The browser shifts it back inside the viewport, onto
289
+ # its own anchor.
290
+ row("SNAP/off_shifted", "PASS" if "data-mosaic-popover-flipped" not in at
291
+ and m["r"] <= m["vw"] + 1 and m["x"] < t["r"] else "FAIL",
292
+ "keepInView=0, not flipped: popover %d..%d inside a %d viewport, over its trigger %d..%d"
293
+ % (m["x"], m["r"], m["vw"], t["x"], t["r"]))
294
+ p.evaluate(CLOSE_ALL)
295
+
296
+ # ---- the flex modal: display:flex on the host, still hidden when closed
297
+ if p.evaluate("() => !!document.getElementById('pp-flexmodal')"):
298
+ st = p.evaluate("""() => { const e = document.getElementById('pp-flexmodal');
299
+ const r = e.getBoundingClientRect(); return [getComputedStyle(e).display, r.width, r.height]; }""")
300
+ row("FLEXMODAL/closed", "PASS" if st[0] == "none" and not st[1] else "FAIL",
301
+ "authored display:flex, closed it computes display:%s %dx%d" % tuple(st))
302
+ p.click("#pp-open-flexmodal")
303
+ p.wait_for_timeout(500)
304
+ st = p.evaluate("""() => { const e = document.getElementById('pp-flexmodal');
305
+ const w = document.getElementById('pp-flexmodal-win').getBoundingClientRect();
306
+ return [getComputedStyle(e).display, e.matches(':popover-open'),
307
+ Math.round(w.x + w.width / 2), Math.round(w.y + w.height / 2),
308
+ innerWidth, innerHeight]; }""")
309
+ centred = abs(st[2] - st[4] / 2) <= 10 and abs(st[3] - st[5] / 2) <= 10
310
+ row("FLEXMODAL/open", "PASS" if st[0] == "flex" and st[1] and centred else "FAIL",
311
+ "display:%s top-layer=%s window centre %d,%d in %dx%d" % tuple(st))
312
+ p.keyboard.press("Escape")
313
+ p.wait_for_timeout(400)
314
+ else:
315
+ row("FLEXMODAL", "SKIPPED", "not on this page")
316
+
317
+ row("NO_JS_ERRORS", "FAIL" if errors else "PASS", "; ".join(errors[:3]) or "none")
318
+
319
+ # ---- a phone: the anchored menu still fits on screen
320
+ if "pp-menu" in present:
321
+ mp = b.new_page(viewport={"width": 390, "height": 844})
322
+ mp.goto(url, wait_until="load")
323
+ mp.wait_for_timeout(1500)
324
+ mp.evaluate(CLOSE_ALL)
325
+ mp.click("#pp-open-menu")
326
+ mp.wait_for_timeout(400)
327
+ r = mp.evaluate(RECT, "pp-menu")
328
+ row("PHONE/menu_fits", "PASS" if r and r["x"] >= 0 and r["r"] <= r["vw"] + 1 else "FAIL",
329
+ "menu %d..%d in a %d viewport" % (r["x"], r["r"], r["vw"]) if r else "did not open")
330
+ mp.close()
331
+
332
+ return finish(a, b)
333
+
334
+
335
+ def finish(a, b):
336
+ fails = sum(1 for r in ROWS if r[1] == "FAIL")
337
+ print("\n%d checks, %d FAIL" % (len(ROWS), fails))
338
+ if a.csv:
339
+ with open(a.csv, "w", newline="", encoding="utf-8") as fh:
340
+ w = csv.writer(fh)
341
+ w.writerow(["check", "result", "detail"])
342
+ w.writerows(ROWS)
343
+ print("wrote", a.csv)
344
+ sys.exit(1 if fails else 0)
345
+
346
+
347
+ if __name__ == "__main__":
348
+ main()