mosaic-headless 1.19.0 → 1.20.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 (38) hide show
  1. package/README.ja.md +6 -4
  2. package/README.ko.md +6 -4
  3. package/README.md +6 -4
  4. package/README.zh-TW.md +6 -4
  5. package/SKILL.md +55 -24
  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/bin/check-release.mjs +4 -3
  12. package/data/accordion-verification.csv +1 -1
  13. package/data/condition-subjects.csv +56 -56
  14. package/data/conversion-batch.csv +2 -2
  15. package/data/data-class-hierarchy.csv +4 -0
  16. package/data/dialog-verification.csv +20 -0
  17. package/data/interaction-types.csv +15 -12
  18. package/data/intro-verification.csv +7 -7
  19. package/data/loop-verification.csv +1 -1
  20. package/data/node-properties.csv +9 -0
  21. package/data/node-property-verification.csv +88 -79
  22. package/data/node-types.csv +13 -9
  23. package/data/node-verification.csv +99 -95
  24. package/data/placement-rules.csv +4 -0
  25. package/data/pluggables.csv +42 -0
  26. package/data/rest-routes.csv +2 -1
  27. package/data/style-properties.csv +6 -6
  28. package/data/style-verification.csv +79 -79
  29. package/data/theme-zip-verification.csv +5 -5
  30. package/data/variants.csv +12 -8
  31. package/package.json +1 -1
  32. package/references/dialog-and-triggers.md +201 -0
  33. package/tools/build_page.py +9 -1
  34. package/tools/sweep_node_properties.py +21 -5
  35. package/tools/sweep_node_types.py +85 -0
  36. package/tools/sweep_style_properties.py +10 -24
  37. package/tools/verify_dialog.py +302 -0
  38. package/tools/verify_rwd.py +11 -27
package/data/variants.csv CHANGED
@@ -143,11 +143,15 @@ eb830eeb-7a4e-05b7-9363-13f3d57d77a8,Cite,m-gutenberg-pullquote-cite,False,tag,"
143
143
  760f938d-5475-48de-a776-6a956df43d53,Outline button,m-gutenberg-outline-button,False,tag,":is(.entry-content, .wp-block-post-content) .wp-block-button__link:where(div.wp-block-button.is-style-outline > a.wp-element-button.wp-block-button__link)",,False,True,True,b1H
144
144
  8de49a77-f767-0054-b1ac-e913ac6562ba,Video,m-video,True,,.m-video,,False,True,False,b1I
145
145
  c637b22c-61b9-0964-bcdb-d5ae0d16986a,Google Maps,m-google-maps,True,,.m-google-maps,,False,True,False,b1J
146
- 0dce60de-c4a6-0a2d-9990-b93070aeb7da,YouTube,m-youtube,True,,.m-youtube,,False,True,False,b1K
147
- be107226-ba10-0fc6-b22e-f984fb21d63b,Vimeo,m-vimeo,True,,.m-vimeo,,False,True,False,b1L
148
- ea692231-151b-02df-bea7-d567347d1422,List,m-list,True,,.m-list,,True,True,False,b1M
149
- 69a8ab3f-dbdf-09bd-8449-6420570155b8,Horizontal list,m-list-horizontal,True,,.m-list-horizontal,ea692231-151b-02df-bea7-d567347d1422,False,True,False,b1N
150
- 3a8cfda3-1ded-0a3c-9e55-b56510827850,Unordered list,m-list-unordered,True,,.m-list-unordered,ea692231-151b-02df-bea7-d567347d1422,False,True,False,b1O
151
- 7dbba514-fd83-0e8f-ac46-68e6f651f2ff,Ordered list,m-list-ordered,True,,.m-list-ordered,ea692231-151b-02df-bea7-d567347d1422,False,True,False,b1P
152
- 1864859b-2942-44db-b09d-c7073d1df01e,Item,m-list-item,True,,.m-list-item,ea692231-151b-02df-bea7-d567347d1422,False,True,False,b1Q
153
- fb0c2625-dd5a-43c5-82e2-22e8c09ca06c,Loop items,m-list-loop-items,True,,.m-list-loop-items,ea692231-151b-02df-bea7-d567347d1422,False,True,False,b1R
146
+ 1a360ae2-3d10-017a-9db6-cece7b44ce17,OpenStreet​Map,m-osm,True,,.m-osm,,False,True,False,b1K
147
+ 0dce60de-c4a6-0a2d-9990-b93070aeb7da,YouTube,m-youtube,True,,.m-youtube,,False,True,False,b1L
148
+ be107226-ba10-0fc6-b22e-f984fb21d63b,Vimeo,m-vimeo,True,,.m-vimeo,,False,True,False,b1M
149
+ ea692231-151b-02df-bea7-d567347d1422,List,m-list,True,,.m-list,,True,True,False,b1N
150
+ 69a8ab3f-dbdf-09bd-8449-6420570155b8,Horizontal list,m-list-horizontal,True,,.m-list-horizontal,ea692231-151b-02df-bea7-d567347d1422,False,True,False,b1O
151
+ 3a8cfda3-1ded-0a3c-9e55-b56510827850,Unordered list,m-list-unordered,True,,.m-list-unordered,ea692231-151b-02df-bea7-d567347d1422,False,True,False,b1P
152
+ 7dbba514-fd83-0e8f-ac46-68e6f651f2ff,Ordered list,m-list-ordered,True,,.m-list-ordered,ea692231-151b-02df-bea7-d567347d1422,False,True,False,b1Q
153
+ 1864859b-2942-44db-b09d-c7073d1df01e,Item,m-list-item,True,,.m-list-item,ea692231-151b-02df-bea7-d567347d1422,False,True,False,b1R
154
+ fb0c2625-dd5a-43c5-82e2-22e8c09ca06c,Loop items,m-list-loop-items,True,,.m-list-loop-items,ea692231-151b-02df-bea7-d567347d1422,False,True,False,b1S
155
+ d1a109c7-3b52-04ef-9a6c-11e0dc0d1a70,Modal,m-modal,True,,.m-modal,,False,True,False,b1T
156
+ d1a1090d-6e4f-04ef-9a6c-11e0dc0d1a71,Window,m-modal-window,True,,.m-modal-window,d1a109c7-3b52-04ef-9a6c-11e0dc0d1a70,False,True,False,b1U
157
+ d1a10004-e51a-04ef-9a6c-11e0dc0d1a72,Overlay,m-modal-overlay,True,,.m-modal-overlay,d1a109c7-3b52-04ef-9a6c-11e0dc0d1a70,False,True,False,b1V
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mosaic-headless",
3
- "version": "1.19.0",
3
+ "version": "1.20.0",
4
4
  "description": "AI-agent skill: build Mosaic Pro (Nextend) WordPress sites by writing the underlying data model directly - 23 custom tables, no visual editor, no DOM. Every node type, style property and node property swept against a live install and asserted on the delivered HTML and compiled CSS. Installs into Claude Code, Cursor, Codex CLI, Gemini CLI, Copilot, Continue, Windsurf and Claude.ai.",
5
5
  "keywords": [
6
6
  "wordpress",
@@ -0,0 +1,201 @@
1
+ # Modal, OpenStreetMap, the new triggers and run rules (1.0.9), measured
2
+
3
+ 1.0.9 is the first release since this skill began that adds *features* rather than
4
+ renaming things: four node types, three interaction triggers, a rule engine that
5
+ decides whether a trigger is allowed to run, two memory actions, and a shorthand
6
+ that collapses "open this modal when …" into one object.
7
+
8
+ Everything below was built on the live site and read back out of the delivered page
9
+ or out of the browser. `data/dialog-verification.csv`, 19 checks, all passing.
10
+
11
+ ## Modal — a real `<dialog>`
12
+
13
+ Three node types: `modal`, and inside it `modal-window` and `modal-overlay`.
14
+
15
+ ```json
16
+ {"type": "modal", "nodeID": "<uuid you mint>",
17
+ "data": {"role": "dialog", "accessibleLabel": "Feature probe dialog",
18
+ "closedby": "everything"},
19
+ "children": [
20
+ {"type": "modal-overlay", "data": {}},
21
+ {"type": "modal-window", "style": {"&": {"_": {"flexDirection": "column"}}},
22
+ "children": [ … ]}]}
23
+ ```
24
+
25
+ delivers
26
+
27
+ ```html
28
+ <dialog class="m-modal M_Dialog M_IJ _g" id="pr-modal" role="dialog"
29
+ aria-label="Feature probe dialog" aria-modal="true"
30
+ data-mosaic-modal-closedby="everything">
31
+ <div class="m-modal-overlay _h" tabindex="-1"></div>
32
+ <div class="m-modal-window _i" tabindex="-1"> … </div>
33
+ </dialog>
34
+ ```
35
+
36
+ - **`aria-modal="true"` is emergent, not authored.** It appears exactly when the
37
+ modal has an overlay child, because the overlay is what actually blocks the page.
38
+ No overlay, no `aria-modal`.
39
+ - **`closedby` is `everything` | `clickOutside` | `esc` | `nothing`**, and it is
40
+ emitted as `data-mosaic-modal-closedby`, NOT as the native `closedby` attribute —
41
+ Mosaic opens the host with `show()`, where the native light-dismiss does not
42
+ apply, so its own controller reads the attribute. Measured: with `everything`,
43
+ Esc closes it. `nothing` is the cookie-gate case, where the only way out is a
44
+ `modalClose` action you place yourself.
45
+ - **`role` has no default.** A `<dialog>` already carries an implicit dialog role;
46
+ the property only emits when you set one (`dialog`, `alertdialog`).
47
+ - **`accessibleLabel` always emits `aria-label`**, empty if you leave it blank — so
48
+ an unlabelled modal ships an empty accessible name. Set it.
49
+ - **`modal-window` arrives as `display:flex`, row.** Three children in a row squeeze
50
+ a `width: max-content` button to zero and Playwright reports it as not visible,
51
+ which is what a reader would see too. Set `flexDirection: "column"`.
52
+
53
+ ## Opening and closing it
54
+
55
+ `modalOpen` and `modalClose` are interaction actions. Their target is the modal's
56
+ **node id**, not its `attrID`:
57
+
58
+ ```json
59
+ {"type": "modalOpen", "uuid": "<uuid>",
60
+ "settings": {"target": {"type": "element", "uuid": "<the modal's node id>"}}}
61
+ ```
62
+
63
+ which the page resolves to `{"targetSelectorData": [{"type": "all", "selector": "._g"}]}`
64
+ — the modal's generated class. Two consequences:
65
+
66
+ - **The spec has to know the node id before either node is written**, because the
67
+ button and the modal are committed in the same pass. `build_page.py` takes a
68
+ `nodeID` on any spec node for exactly this.
69
+ - **That id must be unique in the THEME, not the document.** `wp_mosaic_nodes`'
70
+ primary key is `(ID, themeID)`, so a hard-coded id collides with whatever an
71
+ earlier build left behind — the commit answers HTTP 500 on a duplicate key. Mint
72
+ it per run.
73
+
74
+ `type` accepts `element`, `variant` or `universalClass` — the last two aim an action
75
+ at every element of a class.
76
+
77
+ ## The three new triggers
78
+
79
+ | id | family | fires | default settings |
80
+ |---|---|---|---|
81
+ | `exitIntent` | timed | the pointer leaves the page through the top. **Desktop only** — it never runs on a touch device | `awayFor: 600ms` |
82
+ | `scrollDepth` | timed | the visitor scrolls this far down | `threshold: 50%` |
83
+ | `modal` | timed | the modal opens or closes | — |
84
+
85
+ They are ordinary interaction types, so they take the envelope the rest of the
86
+ language uses (`references/interactions.md`). The interaction type list is also
87
+ re-grouped in 1.0.9 — Mouse / Page & scroll / Element events — which changes the
88
+ `family` column of `data/interaction-types.csv` and nothing else.
89
+
90
+ ## Run rules: how often a trigger may run
91
+
92
+ A timed interaction can carry `runs`, and that is what makes a popup shippable:
93
+
94
+ ```json
95
+ "runs": {"rules": [{"uuid": "<uuid>", "type": "cap",
96
+ "capOptions": {"max": "1", "remember": "session",
97
+ "name": "probe-exit-modal"}}]}
98
+ ```
99
+
100
+ | type | options | what it means |
101
+ |---|---|---|
102
+ | `cap` | `max` | run at most N times |
103
+ | `cooldown` | `duration`, `durationUnit` | wait this long between runs |
104
+ | `firstRunWindow` | `duration`, `durationUnit` | only within this long of the first run |
105
+ | `pickOne` | — | one of several interactions sharing a name runs; the name is the group, so it is required here |
106
+
107
+ `durationUnit` is `minute` | `hour` | `day`. `remember` is `page` | `session` |
108
+ `forever`, and `name` must match `^[a-z][a-z0-9_-]*$` — a rule with a name outside
109
+ that grammar is skipped rather than stored.
110
+
111
+ What that writes, measured: `localStorage` **and** `sessionStorage`, both under
112
+ `mos:v1:r:<name>`, holding `{"counter":1,"firstAt":<ms>,"lastAt":<ms>}`. Only
113
+ `timed` interactions support run rules; a progress interaction has no "run" to count.
114
+
115
+ **`remember: "session"` is sessionStorage, which browsers scope per TAB.** A test
116
+ that opens a second tab to prove a cap is testing nothing — reload the same tab.
117
+ That mistake made the cap look broken here until the tab was reused.
118
+
119
+ ## Memory: remember / forget
120
+
121
+ Two actions that write and clear the same records a run rule reads, so one
122
+ interaction can decide what another is allowed to do.
123
+
124
+ ```json
125
+ {"type": "remember", "uuid": "<uuid>",
126
+ "rememberOptions": {"settings": {"name": "probe-seen"}}}
127
+ ```
128
+
129
+ **The `name` goes under `<type>Options.settings`.** Four other placements were
130
+ committed and silently dropped — `rememberOptions.name`, `settings.name`,
131
+ `rememberOptions.timelines._.name`, and a bare top-level `name` — and the action
132
+ then runs as a no-op that writes nothing, because the source skips an unnamed
133
+ memory rather than inventing a key. The server reshapes the accepted one into
134
+ `rememberOptions.timelines._.name`, which is what the payload shows.
135
+
136
+ ## The interaction shorthand — modals only
137
+
138
+ Every element's data can carry `interactionShorthand`, but `ModalElementMResourceData`
139
+ is the only class that overrides `getInteractionShorthandActionTypes()` (it returns
140
+ `['modalOpen']`), so it is the only element the shorthand does anything for. The
141
+ feature is "open this modal when …" in the modal's own settings:
142
+
143
+ ```json
144
+ "interactionShorthand": {
145
+ "type": "exitIntent",
146
+ "exitIntentOptions": {
147
+ "settings": {"awayFor": "400ms"},
148
+ "runs": {"rules": [{"uuid": "<uuid>", "type": "cap",
149
+ "capOptions": {"max": "1", "remember": "session",
150
+ "name": "probe-exit-modal"}}]}}}
151
+ ```
152
+
153
+ At render that expands into a full interaction on the modal itself — trigger from
154
+ the type, action from the element — and the page's `mosaicInteractions` gains
155
+ `{"type":"exitIntent","triggerSelector":"._0","action":{"exitIntent":{"actions":[{"type":"modalOpen"…`
156
+ with the cap attached. No trigger element, no action, no target: an exit-intent
157
+ popup that fires once a session is one object.
158
+
159
+ `"type": "manual"` is the stored answer for "nothing opens this automatically" —
160
+ spelled as a word rather than left absent, because absent means *inherit* on a
161
+ component instance.
162
+
163
+ The shorthand cannot carry conditions, and that is a property of the data shape
164
+ rather than a rule to remember: it has no field for them.
165
+
166
+ ## OpenStreetMap
167
+
168
+ `openstreetmap` renders `<mosaic-openstreetmap>` wrapping an `<iframe>` at
169
+ openstreetmap.org's own embed endpoint. Five properties:
170
+
171
+ | property | shape | values |
172
+ |---|---|---|
173
+ | `latitude`, `longitude` | **`{"v": "25.0330"}`** | −90..90, −180..180 |
174
+ | `zoom` | **`{"v": "15"}`** | 0..19 |
175
+ | `layer` | bare string | `mapnik`, `cyclosm`, `cyclemap`, `transportmap`, `hot` |
176
+ | `lazyLoad` | bare string | `"0"` / `"1"` → `loading="lazy"` on the iframe |
177
+
178
+ The first three carry `ValidatorDynamicCodeObject` in their chain, so they take the
179
+ `{"v": …}` object form the rest of the dynamic language uses — a bare string is
180
+ dropped and the map quietly shows its default location (Times Square) with no error
181
+ anywhere. That is the skill's existing rule (`ValidatorDynamicCode` = bare string,
182
+ `ValidatorDynamicCodeObject` = `{"v": …}`) applying to a new element, and it is also
183
+ why those three can hold `@VAR('post/meta_lat')` and a custom field can drive the map.
184
+
185
+ ## What else moved in the delivered page
186
+
187
+ **The three per-breakpoint stylesheets became one.** Through 1.0.8 a page carried
188
+ `mosaic-theme-block-editor-styles_-inline-css`, `…_t-…` and `…_m-…`; 1.0.9 emits a
189
+ single `mosaic-theme-document-styles-inline-css` with the breakpoints as `@media`
190
+ blocks inside it. Any tool that read a breakpoint by block id reads nothing now —
191
+ `document_styles()` in `tools/sweep_node_types.py` handles both, and the base is now
192
+ "everything outside an at-rule" rather than "its own block".
193
+
194
+ **An element whose styles compile to nothing gets no generated class.**
195
+ `<div class="m-div" id="sp-015">` — no `_token`. On 1.0.8 every element had one. So
196
+ the twenty inert grouped properties are now visible in the markup as well as absent
197
+ from the CSS, and a checker must not read "no token" as "the node did not render".
198
+
199
+ **`youtube`'s `privacy` property suppresses the element.** Set on its own it emits
200
+ nothing at all, where 1.0.8 rendered the element and recorded NO_EFFECT. One row of
201
+ `data/node-property-verification.csv`, the only `NO_OUTPUT` in the table.
@@ -238,7 +238,15 @@ def flatten(node, parent_id, master_id, surface, force, ordering="a0", parent_ty
238
238
  if problems:
239
239
  raise SystemExit("refusing to build:\n " + "\n ".join(problems))
240
240
 
241
- nid = str(uuid.uuid4())
241
+ # `nodeID` pins the node's own id instead of minting one. An interaction that
242
+ # targets another element stores that element's NODE id (`settings.target =
243
+ # {"type": "element", "uuid": <nodeID>}`), not its attrID, so the two have to be
244
+ # written in the same pass - there is no way to point at a node whose id the
245
+ # spec never sees. The id must be unique in the THEME, not the document:
246
+ # wp_mosaic_nodes' primary key is (ID, themeID), so a hard-coded one collides
247
+ # with whatever a previous build left behind and the commit 500s on a duplicate
248
+ # key. Mint it per run.
249
+ nid = node.get("nodeID") or str(uuid.uuid4())
242
250
  data = dict(node.get("data") or {})
243
251
  style = to_style(node.get("style"))
244
252
  if style:
@@ -132,7 +132,11 @@ def searchable(tag):
132
132
  def judge(prop, value, tag):
133
133
  """Did this property change the delivered markup the way it claims to?"""
134
134
  if tag is None:
135
- return "NO_ELEMENT", ""
135
+ # The probe was committed and the page rendered, so the node exists; the
136
+ # property suppressed its own element's output. `youtube.privacy` does
137
+ # exactly this since 1.0.9 - on 1.0.8 the same probe rendered and read
138
+ # NO_EFFECT - so it is its own outcome, not a missing host.
139
+ return "NO_OUTPUT", "the element emitted nothing with this property set"
136
140
  body = searchable(tag)
137
141
  if prop == "cssClasses":
138
142
  return ("APPLIED" if MARK + "-cls" in tag else "NO_EFFECT"), tag[:110]
@@ -265,9 +269,21 @@ def main():
265
269
  # from an earlier run share the id space, and two runs can give the same id
266
270
  # to different node types. If the page does not carry exactly the probes
267
271
  # this run planned, nothing measured from it means anything.
268
- sys.exit("expected %d probes on the page, found %d - clear previous probes "
269
- "and re-run before believing any measurement"
270
- % (len(live) - len(moved), rendered))
272
+ on_page = set(re.findall(r'id="(np-\d+)"', html))
273
+ extra = sorted(on_page - {attr for attr, _h, _p, _v in live})
274
+ # An id this run did not plan is contamination: two runs can give the same
275
+ # id to different node types and nothing measured from the page would mean
276
+ # anything. A probe that this run DID plan and that is simply not there is
277
+ # a result, not contamination - the property suppressed its own element -
278
+ # so it is recorded as NO_OUTPUT below instead of aborting the run.
279
+ if extra:
280
+ sys.exit("probes on the page that this run did not plan: %s\n"
281
+ "clear them and re-run before believing any measurement"
282
+ % ", ".join(extra))
283
+ missing = [(attr, host, prop) for attr, host, prop, _v in live
284
+ if attr not in on_page and attr not in moved]
285
+ print(" %d planned probe(s) rendered nothing: %s"
286
+ % (len(missing), ", ".join("%s.%s" % (h, p) for _a, h, p in missing)))
271
287
 
272
288
  rows = []
273
289
  for attr, host, prop, value in live:
@@ -291,7 +307,7 @@ def main():
291
307
  for r in rows:
292
308
  counts[r[3]] = counts.get(r[3], 0) + 1
293
309
  print()
294
- for k in ("APPLIED", "NO_EFFECT", "EDITOR_ONLY", "NO_ELEMENT", "NO_HOST",
310
+ for k in ("APPLIED", "NO_EFFECT", "NO_OUTPUT", "EDITOR_ONLY", "NO_ELEMENT", "NO_HOST",
295
311
  "INSTRUMENT", "SKIPPED"):
296
312
  if counts.get(k):
297
313
  print(" %-12s %d" % (k, counts[k]))
@@ -117,6 +117,91 @@ def token_by_id(html, id_pattern):
117
117
  return out
118
118
 
119
119
 
120
+ def document_styles(html, suffix=""):
121
+ """{generated element class: {css property: value}} for one breakpoint's rules.
122
+
123
+ `suffix` is the breakpoint's own key with the leading underscore dropped: "" is
124
+ the base, "t" tablet, "m" mobile - the same spelling the three separate style
125
+ blocks used before 1.0.9.
126
+
127
+ 1.0.9 merged those three blocks into ONE `mosaic-theme-document-styles-inline-css`
128
+ with the breakpoints as `@media` blocks inside it, so a suffix is now a position
129
+ in one stylesheet rather than a block id. The old ids are still read first, so
130
+ the same tool measures a 1.0.8 site unchanged.
131
+
132
+ The base is everything OUTSIDE any at-rule: scanning the whole block for `._x{...}`
133
+ would read a tablet override as if it were the base, which is exactly the error
134
+ the three-block layout made impossible.
135
+ """
136
+ old = re.search(
137
+ r'<style id="mosaic-theme-block-editor-styles_%s-inline-css">(.*?)</style>'
138
+ % re.escape(suffix), html, re.S)
139
+ if old:
140
+ css = old.group(1)
141
+ inner = re.search(r"@media[^{]*\{(.*)\}\s*$", css, re.S)
142
+ return _rules_of(inner.group(1) if inner else css)
143
+
144
+ one = re.search(r'<style id="mosaic-theme-document-styles-inline-css">(.*?)</style>',
145
+ html, re.S)
146
+ if not one:
147
+ return None
148
+ css = one.group(1)
149
+ blocks = _at_rule_blocks(css)
150
+ if suffix == "":
151
+ outside = css
152
+ for start, end, _head in blocks:
153
+ outside = outside.replace(css[start:end], " ")
154
+ return _rules_of(outside)
155
+ # a breakpoint block is a max-width media query; they appear widest-first, the
156
+ # order Mosaic emits them in, so "t" is the first and "m" the second
157
+ media = [(s, e, h) for s, e, h in blocks if "max-width" in h]
158
+ want = {"t": 0, "m": 1}.get(suffix)
159
+ if want is None or want >= len(media):
160
+ return None
161
+ s, e, _h = media[want]
162
+ body = css[css.index("{", s) + 1:e - 1]
163
+ return _rules_of(body)
164
+
165
+
166
+ def _at_rule_blocks(css):
167
+ """[(start, end, header)] for every top-level at-rule, found by brace depth."""
168
+ out, i, n = [], 0, len(css)
169
+ while True:
170
+ at = css.find("@", i)
171
+ if at < 0:
172
+ return out
173
+ brace = css.find("{", at)
174
+ if brace < 0:
175
+ return out
176
+ depth, j = 0, brace
177
+ while j < n:
178
+ if css[j] == "{":
179
+ depth += 1
180
+ elif css[j] == "}":
181
+ depth -= 1
182
+ if depth == 0:
183
+ break
184
+ j += 1
185
+ out.append((at, j + 1, css[at:brace]))
186
+ i = j + 1
187
+
188
+
189
+ def _rules_of(css):
190
+ """{class: {prop: value}} from flat CSS, ignoring anything nested in an at-rule."""
191
+ rules = {}
192
+ for sel, decls in re.findall(r"([^{}]+)\{([^{}]*)\}", css):
193
+ props = {}
194
+ for decl in decls.split(";"):
195
+ if ":" in decl:
196
+ k, v = decl.split(":", 1)
197
+ props[k.strip()] = v.strip()
198
+ for one in sel.split(","):
199
+ cls = re.match(r"^\.(_[a-z0-9_-]+)$", one.strip())
200
+ if cls:
201
+ rules.setdefault(cls.group(1), {}).update(props)
202
+ return rules
203
+
204
+
120
205
  def envelopes(doc):
121
206
  return {k: [[x["ID"], x["revision"]] for x in v] for k, v in doc.items()}
122
207
 
@@ -55,7 +55,8 @@ import sys
55
55
 
56
56
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
57
57
  from build_page import Surface, flatten, ordering_for # noqa: E402
58
- from sweep_node_types import Client, envelopes, exceptions_of, token_by_id, unwrap # noqa: E402
58
+ from sweep_node_types import ( # noqa: E402
59
+ Client, document_styles, envelopes, exceptions_of, token_by_id, unwrap)
59
60
 
60
61
  MARK_LEN = "37px"
61
62
  MARK_COLOR = "rgb(9, 99, 199)"
@@ -130,27 +131,6 @@ def test_value(row):
130
131
  return None
131
132
 
132
133
 
133
- def parse_rules(html, suffix=""):
134
- """{generated element class: {css property: value}} for one compiled stylesheet."""
135
- m = re.search(
136
- r'<style id="mosaic-theme-block-editor-styles_%s-inline-css">(.*?)</style>'
137
- % suffix, html, re.S)
138
- rules = {}
139
- if not m:
140
- return rules
141
- for sel, decls in re.findall(r"([^{}]+)\{([^{}]*)\}", m.group(1)):
142
- cls = re.match(r"^\.(_[a-z0-9_-]+)$", sel.strip())
143
- if not cls:
144
- continue
145
- props = {}
146
- for d in decls.split(";"):
147
- if ":" in d:
148
- k, v = d.split(":", 1)
149
- props[k.strip()] = v.strip()
150
- rules.setdefault(cls.group(1), {}).update(props)
151
- return rules
152
-
153
-
154
134
  def main():
155
135
  ap = argparse.ArgumentParser()
156
136
  ap.add_argument("--config", required=True)
@@ -204,7 +184,7 @@ def main():
204
184
  if not classes:
205
185
  sys.exit("no probe rendered - check ordering and parent before trusting a run")
206
186
 
207
- rules = parse_rules(html)
187
+ rules = document_styles(html) or {}
208
188
 
209
189
  rows = []
210
190
  for attr, key_name, value in planned:
@@ -217,7 +197,13 @@ def main():
217
197
  (v for k, v in got.items() if k.startswith(name)), None)
218
198
  want = value if isinstance(value, str) else None
219
199
  if cls is None:
220
- status = "NO_ELEMENT"
200
+ # Since 1.0.9 an element whose local styles compile to NOTHING is
201
+ # emitted without a generated class at all - `<div class="m-div"
202
+ # id="sp-015">`, no `_token`. So "no class" no longer means the node
203
+ # failed to render; for a probe that did render it means the property
204
+ # produced no CSS, which is what ABSENT has always meant. The two are
205
+ # told apart by looking for the id in the markup.
206
+ status = "ABSENT" if ('id="%s"' % attr) in html else "NO_ELEMENT"
221
207
  elif hit is None:
222
208
  status = "ABSENT"
223
209
  elif want is None or want.replace(" ", "") in hit.replace(" ", ""):