mosaic-headless 1.21.2 → 1.22.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 CHANGED
@@ -4,7 +4,7 @@ description: |
4
4
  Build and modify Mosaic Pro (Nextend) sites by writing the underlying data model directly - no visual editor, no DOM. Query the real surface with `mo.py`, which joins every source table to the live sweeps so a lookup leads with the measured verdict rather than the declaration (126 node types, 191 properties, 98 style properties with 20 structured value shapes pinned down, 53 style states, 156 variants, 74 dynamic variables, 15 interaction triggers, 115 REST routes, 23 tables) instead of guessing, with every node type placed on a live site one at a time and asserted against the delivered HTML, the design-token and element-class layers verified against compiled CSS, the @VAR() dynamic language verified against rendered output, nine designed pages built through the tables themselves, and the delivered pages re-read in Chromium at three viewports so a rule that is present, correct and still wrong cannot pass. Drives Mosaic's own theme export/import and its plugin data upgrade from outside the editor and holds the copy against the source tree for tree. Measured on Mosaic Pro 1.0.9: the modal (`<dialog>`), OpenStreetMap, the exit-intent and scroll-depth triggers, run rules and the memory actions all built on a live page and read back, and the 1.0.7 -> 1.0.8 -> 1.0.9 migrations driven from outside wp-admin.
5
5
  license: "MIT"
6
6
  author: "moksa (https://moksaweb.com)"
7
- version: "1.21.2"
7
+ version: "1.22.0"
8
8
  ---
9
9
 
10
10
  # Headless Mosaic
@@ -812,6 +812,7 @@ post — `build_all.py` resets first for that reason.
812
812
  | `copy_styles.py` | push one node's style onto others, by attrID or prefix |
813
813
  | `bootstrap_probe_theme.php` | a licence-free scratch theme |
814
814
  | `mint_session.php` | a matching cookie + `wp_rest` nonce from WP-CLI |
815
+ | `check_tables.py` | re-extract all eleven source-derived tables and diff them against the shipped ones - the check for a table that is not wrong but STALE, which nothing else can see |
815
816
 
816
817
  ## Regenerating everything
817
818
 
@@ -823,6 +824,12 @@ python tools/extract_default_children.py <plugin-root> data/
823
824
  python tools/extract_style_properties.py <plugin-root> data/
824
825
  python tools/extract_interactions.py <plugin-root> data/
825
826
  python tools/extract_dynamic_variables.py <plugin-root> data/
827
+
828
+ # and then, always: a table extracted BEFORE its extractor was repaired keeps
829
+ # describing the old source and ships silently. Row counts cannot see it and the
830
+ # release gate never sees the plugin. Run this after ANY change to an extractor,
831
+ # not only after a plugin upgrade.
832
+ python tools/check_tables.py <plugin-root>
826
833
  python tools/capture_live.py data/ # from data/raw/*.json + db-columns.txt
827
834
  # (raw dumps are gitignored;
828
835
  # re-capture from a live site)
@@ -20,7 +20,7 @@
20
20
  "description": "Build and modify Mosaic Pro (Nextend) sites by writing the underlying data model directly - no visual editor, no DOM. Query the real surface (122 node types, 181 node properties, 98 style properties, 53 style states, 151 element classes, 74 dynamic variables, 114 REST routes, 23 tables) instead of guessing, with every node type placed on a live site one at a time and asserted against the delivered HTML, every style and node property swept against the compiled CSS and markup, and 569 responsive declarations checked against the stylesheet the site actually served.",
21
21
  "license": "MIT",
22
22
  "author": "moksa (https://moksaweb.com)",
23
- "version": "1.21.2"
23
+ "version": "1.22.0"
24
24
  },
25
25
  "loaderBehaviour": "Upload via Settings -> Skills -> Upload. Claude.ai parses SKILL.md frontmatter and surfaces the skill in your library. The extraction tool (extract-block-schema.php) needs a live WP-CLI connection and won't run in the sandbox; use it from a local terminal against your own site instead.",
26
26
  "uploadSteps": [
@@ -20,7 +20,7 @@
20
20
  "description": "Build and modify Mosaic Pro (Nextend) sites by writing the underlying data model directly - no visual editor, no DOM. Query the real surface (122 node types, 181 node properties, 98 style properties, 53 style states, 151 element classes, 74 dynamic variables, 114 REST routes, 23 tables) instead of guessing, with every node type placed on a live site one at a time and asserted against the delivered HTML, every style and node property swept against the compiled CSS and markup, and 569 responsive declarations checked against the stylesheet the site actually served.",
21
21
  "license": "MIT",
22
22
  "author": "moksa (https://moksaweb.com)",
23
- "version": "1.21.2"
23
+ "version": "1.22.0"
24
24
  },
25
25
  "loaderBehaviour": "Auto-loads on session start when SKILL.md frontmatter parses successfully.",
26
26
  "verified": true,
@@ -20,7 +20,7 @@
20
20
  "description": "Build and modify Mosaic Pro (Nextend) sites by writing the underlying data model directly - no visual editor, no DOM. Query the real surface (122 node types, 181 node properties, 98 style properties, 53 style states, 151 element classes, 74 dynamic variables, 114 REST routes, 23 tables) instead of guessing, with every node type placed on a live site one at a time and asserted against the delivered HTML, every style and node property swept against the compiled CSS and markup, and 569 responsive declarations checked against the stylesheet the site actually served.",
21
21
  "license": "MIT",
22
22
  "author": "moksa (https://moksaweb.com)",
23
- "version": "1.21.2"
23
+ "version": "1.22.0"
24
24
  },
25
25
  "loaderBehaviour": "Confirmed (2026-07-11): Codex CLI natively supports the SKILL.md spec. Place SKILL.md under .codex/skills/<name>/ (project) or ~/.codex/skills/<name>/ (personal) and Codex loads the name+description at session start, then the full body on demand. A parallel, broader convention .agents/skills/ (searched from cwd up to repo root, then ~/.agents/skills/) also exists across multiple tools - if your Codex CLI version prioritizes that path instead, mirror the same SKILL.md there.",
26
26
  "verified": true,
@@ -20,7 +20,7 @@
20
20
  "description": "Build and modify Mosaic Pro (Nextend) sites by writing the underlying data model directly - no visual editor, no DOM. Query the real surface (122 node types, 181 node properties, 98 style properties, 53 style states, 151 element classes, 74 dynamic variables, 114 REST routes, 23 tables) instead of guessing, with every node type placed on a live site one at a time and asserted against the delivered HTML, every style and node property swept against the compiled CSS and markup, and 569 responsive declarations checked against the stylesheet the site actually served.",
21
21
  "license": "MIT",
22
22
  "author": "moksa (https://moksaweb.com)",
23
- "version": "1.21.2"
23
+ "version": "1.22.0"
24
24
  },
25
25
  "loaderBehaviour": "CHANGED as of 2026-07-11: GitHub Copilot added a proper '.github/skills/' Agent Skills directory (December 2025), alongside the older single-file .github/copilot-instructions.md convention. This config targets the new skills-directory form. If your Copilot version predates this (pre Dec 2025), use the instructions-append fallback instead (see fallback below).",
26
26
  "fallback": {
@@ -20,7 +20,7 @@
20
20
  "description": "Build and modify Mosaic Pro (Nextend) sites by writing the underlying data model directly - no visual editor, no DOM. Query the real surface (122 node types, 181 node properties, 98 style properties, 53 style states, 151 element classes, 74 dynamic variables, 114 REST routes, 23 tables) instead of guessing, with every node type placed on a live site one at a time and asserted against the delivered HTML, every style and node property swept against the compiled CSS and markup, and 569 responsive declarations checked against the stylesheet the site actually served.",
21
21
  "license": "MIT",
22
22
  "author": "moksa (https://moksaweb.com)",
23
- "version": "1.21.2"
23
+ "version": "1.22.0"
24
24
  },
25
25
  "loaderBehaviour": "CHANGED as of 2026-07-11: Gemini CLI now natively supports the same SKILL.md standard as Claude Code and Codex CLI - the same directory-based skill works unmodified. Gemini CLI discovers skills in this precedence order: built-in, extension skills, ~/.gemini/skills/ (personal), .gemini/skills/ (project, shared via version control). At session start Gemini injects each discovered skill's name+description into the system prompt and calls activate_skill when a task matches.",
26
26
  "verified": true,
@@ -0,0 +1,14 @@
1
+ subject,case,result,detail
2
+ pickOne,"12 fresh loads, remember=page",PASS,exactly one of the three modals opened every time; 4/6/2 across the group
3
+ pickOne,"8 reloads of one tab, remember=page",PASS,re-drawn on every load: 6/1/1
4
+ pickOne,"8 reloads of one tab, remember=forever",PASS,the same contender all 8 times
5
+ pickOne,"6 fresh browser contexts, remember=forever",PASS,draws again in a new context: 1/3/2 - the record is per browser profile
6
+ pickOne,storage record,PASS,"mos:v1:r:<name> gains lastID, naming the contender the draw chose"
7
+ targetModifiers,plain element,PASS,"[{""type"":""all"",""selector"":""._e""}]"
8
+ targetModifiers,plain variant,PASS,"[{""type"":""all"",""selector"":""h2,.m-heading-2""}] - a variant resolves to its own selectors"
9
+ targetModifiers,children(variant),PASS,"appends {""type"":""children"",""selector"":""h2,.m-heading-2""}"
10
+ targetModifiers,sibling(variant),PASS,"appends {""type"":""sibling"",...}"
11
+ targetModifiers,parent(variant),PASS,"appends {""type"":""parent"",...}"
12
+ targetModifiers,include(element),PASS,"appends {""type"":""include"",...}"
13
+ targetModifiers,exclude(element),PASS,"appends {""type"":""exclude"",...}"
14
+ targetModifiers,children then exclude,PASS,"three entries, in the order written - the list is a pipeline, not a filter set"
@@ -0,0 +1,13 @@
1
+ validator,probed_on,sent,result,note
2
+ ValidatorInteger,maxlength on textarea-input,"""12""",12,accepted
3
+ ValidatorInteger,maxlength on textarea-input,"""12abc""",12,"trailing garbage ignored - parseInt semantics, new in 1.0.9"
4
+ ValidatorInteger,maxlength on textarea-input,"""abc""",(absent),"no leading digits, rejected"
5
+ ValidatorInteger,maxlength on textarea-input,"""-5x""",(absent),"parses to -5, then refused by the property's own min:0 - the parser accepted it"
6
+ ValidatorInteger,maxlength on textarea-input,""" 7 """,7,leading and trailing whitespace tolerated
7
+ ValidatorInteger,maxlength on textarea-input,"""0012""",12,"leading zeros are decimal, not octal"
8
+ ValidatorInteger,maxlength on textarea-input,"""1e3""",1,NOT 1000 - the exponent is trailing garbage
9
+ ValidatorInteger,maxlength on textarea-input,"""12.9""",12,"truncated toward zero, not rounded"
10
+ stretch,width,"""stretch""",width:-webkit-fill-available then width:stretch,"both emitted, prefix first"
11
+ stretch,height,"""stretch""",height:-webkit-fill-available then height:stretch,both emitted
12
+ stretch,maxWidth,"""stretch""",max-width:-webkit-fill-available then max-width:stretch,both emitted
13
+ stretch,width,"""STRETCH""",width:STRETCH,"kept as typed - the match is case-sensitive, and CSS keywords are not, so it still applies"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mosaic-headless",
3
- "version": "1.21.2",
3
+ "version": "1.22.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",
@@ -119,6 +119,33 @@ What that writes, measured: `localStorage` **and** `sessionStorage`, both under
119
119
  that opens a second tab to prove a cap is testing nothing — reload the same tab.
120
120
  That mistake made the cap look broken here until the tab was reused.
121
121
 
122
+ ### `pickOne`, measured
123
+
124
+ `pickOne` is the one rule that is about a GROUP rather than one interaction, and the
125
+ group is the `name`: give three modals the same `name` and exactly one of them opens.
126
+ Three `pageLoad` modals sharing `probe-pick` were loaded repeatedly:
127
+
128
+ | what | result |
129
+ |---|---|
130
+ | 12 fresh browser contexts, `remember: "page"` | one modal every time, 4 / 6 / 2 across the three |
131
+ | 8 reloads of the SAME tab, `remember: "page"` | re-drawn each load — 6 / 1 / 1 |
132
+ | 8 reloads of the same tab, `remember: "forever"` | the same contender all 8 times |
133
+ | 6 fresh contexts, `remember: "forever"` | draws again — 1 / 3 / 2 |
134
+
135
+ No load ever opened more than one. So `remember` on `pickOne` does not mean "how long
136
+ until it may run again", it means **how long the draw is kept**: `page` re-rolls on
137
+ every navigation, `session` and `forever` hand one visitor the same variant each time
138
+ — which is the whole point, since a popup that changes on reload reads as a bug. The
139
+ last row is the limit worth knowing: `forever` is localStorage, so it is per browser
140
+ profile, and a fresh context is a fresh visitor who draws again.
141
+
142
+ The record gains one field for this: alongside `counter` / `firstAt` / `lastAt`,
143
+ `mos:v1:r:<name>` carries **`lastID`**, the uuid of the interaction that won the draw.
144
+ That is what makes the choice stick, and it is also how you read the drawn variant
145
+ back in a test without inspecting which modal is open.
146
+
147
+ `data/interaction-rules-verification.csv` has the runs.
148
+
122
149
  ## Memory: remember / forget
123
150
 
124
151
  Two actions that write and clear the same records a run rule reads, so one
@@ -294,3 +294,27 @@ The other half of this finding is about the checkers: WordPress's "critical erro
294
294
  page is 2,697 bytes, above the 2,000-byte healthy floor, and `Client.page()` was
295
295
  returning an HTTPError's body as the page - so `build_site` announced `OK` over a
296
296
  500 and a whole batch believed it. A 5xx now returns an empty body.
297
+
298
+ ## A shipped table can be stale rather than wrong
299
+
300
+ The failure this skill nearly shipped three times in a row is not a bad value, it is
301
+ an old one. `data/node-types.csv` carried an empty `default_element_class` for three
302
+ releases: the column's regex was repaired AFTER the extraction run that produced the
303
+ table, so the file went on describing the previous source and nothing compared the
304
+ two again.
305
+
306
+ Nothing in place could see it. Row counts were right the whole time — the table had
307
+ the correct number of rows, with one column quietly blank. The release gate counts
308
+ verification tables but never sees the plugin source, because that source is licensed
309
+ third-party code and is not in the repository, so it cannot re-derive anything. And a
310
+ reader of the skill has no way to tell a legitimately-empty column from a lost one.
311
+
312
+ `tools/check_tables.py` closes it: it re-runs all seven extractors into a tempdir and
313
+ diffs every output against the shipped file, naming the first differing line rather
314
+ than just the filename. The moment to run it is **after any change to a
315
+ `tools/extract_*.py`**, not only after a plugin upgrade — the upgrade is the case
316
+ everyone remembers.
317
+
318
+ The general shape, worth carrying beyond this repo: a derived artifact checked into
319
+ the tree is only as fresh as the last time someone re-derived it, and if the deriving
320
+ input is not in the tree, no amount of CI will notice.
@@ -143,6 +143,39 @@ Mosaic also *added* a `uuid` of its own. So on this data model:
143
143
 
144
144
  That check is worth applying to any deeply-nested Mosaic write, not just interactions.
145
145
 
146
+ ## Aiming an action somewhere else: `targetModifiers`
147
+
148
+ Every pluggable action takes a `settings.target`, and a target is either
149
+ `{"type": "element", "uuid": "<nodeID>"}` or `{"type": "variant", "uuid": "<variantID>"}`.
150
+ `settings.targetModifiers` then narrows or widens it — a list of
151
+ `{"uuid", "type", "data"}` where `data` is another target of the same two shapes and
152
+ `type` is one of `children`, `sibling`, `parent`, `include`, `exclude`.
153
+
154
+ The thing worth knowing is what that compiles to. The page hands the runtime a
155
+ `targetSelectorData` **pipeline**: one entry for the base target, then one per
156
+ modifier, applied in the order written.
157
+
158
+ ```
159
+ plain element [{"type":"all","selector":"._e"}]
160
+ plain variant [{"type":"all","selector":"h2,.m-heading-2"}]
161
+ element + children(variant) [{"type":"all","selector":"._e"},
162
+ {"type":"children","selector":"h2,.m-heading-2"}]
163
+ element + sibling(variant) [... {"type":"sibling", ...}]
164
+ element + parent(variant) [... {"type":"parent", ...}]
165
+ variant + include(element) [... {"type":"include", ...}]
166
+ variant + exclude(element) [... {"type":"exclude", ...}]
167
+ children then exclude three entries, in that order
168
+ ```
169
+
170
+ Two consequences. A **variant target resolves to the variant's own CSS selectors**,
171
+ so "every H2" costs you nothing — you do not have to enumerate nodes. And because it
172
+ is a pipeline rather than a filter set, **order matters**: `children` then `exclude`
173
+ excludes from the children, while the reverse excludes from the base and then
174
+ descends. Write the modifiers in the order you want them applied.
175
+
176
+ All eight shapes above were committed and read back from the delivered page;
177
+ `data/interaction-rules-verification.csv` has them.
178
+
146
179
  ## The surface, extracted
147
180
 
148
181
  `data/interaction-types.csv` — 12 trigger types in two families:
@@ -77,3 +77,34 @@ A failed validator does not return an HTTP error. The response is a
77
77
  `RESTJSONExceptionEnvelope`: HTTP 200 with an `exceptions` array in the body. Code
78
78
  that only checks the status code will report a clean save on a rejected write.
79
79
  Always read `exceptions` before believing a commit landed.
80
+
81
+ ## Validators coerce before they reject — a number need not arrive as a number
82
+
83
+ Silent rejection is the documented hazard, but the quieter one is silent *acceptance*
84
+ of something other than what you sent. As of 1.0.9 `ValidatorInteger` mirrors
85
+ JavaScript's `parseInt()`: it takes the leading integer and discards the rest, where
86
+ the previous release fell back to PHP's `intval()` and treated a zero result as a
87
+ failure. Measured on `textarea-input`'s `maxlength`:
88
+
89
+ | sent | stored | |
90
+ |---|---|---|
91
+ | `"12"` | 12 | |
92
+ | `" 7 "` | 7 | whitespace tolerated |
93
+ | `"0012"` | 12 | decimal, not octal |
94
+ | `"12abc"` | **12** | trailing garbage ignored |
95
+ | `"12.9"` | **12** | truncated toward zero, not rounded |
96
+ | `"1e3"` | **1** | **not** 1000 — the exponent is trailing garbage |
97
+ | `"abc"` | absent | no leading digits |
98
+ | `"-5x"` | absent | parsed fine as −5; refused by the property's own `min: 0` |
99
+
100
+ `"1e3"` is the row to remember: a value that looks numeric to a human, is numeric to
101
+ `floatval()`, and lands as 1. A typo in a unit (`"16px"` where a bare integer was
102
+ wanted) likewise stores 16 and never complains. Send integers as integers, and read
103
+ the row back when you did not.
104
+
105
+ The companion change is `ValidatorMimeType`, which now accepts any number of media
106
+ type parameters including quoted values containing spaces — so
107
+ `video/mp4; codecs="avc1.42E01E, mp4a.40.2"` is valid where 1.0.8 rejected it.
108
+
109
+ `data/value-coercion-verification.csv` has these runs, along with the `stretch`
110
+ keyword's emitted declarations.
@@ -102,6 +102,8 @@ const counts = {
102
102
  "data/slider-verification.csv": 19,
103
103
  "data/navigation-verification.csv": 46,
104
104
  "data/page-modal-verification.csv": 19,
105
+ "data/interaction-rules-verification.csv": 13,
106
+ "data/value-coercion-verification.csv": 12,
105
107
  };
106
108
  for (const [file, expected] of Object.entries(counts)) {
107
109
  if (!fs.existsSync(path.join(ROOT, file))) { fail(`${file} missing`); continue; }
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env python3
2
+ """Re-extract every source-derived table and fail if a shipped one differs.
3
+
4
+ python tools/check_tables.py <plugin-root>
5
+
6
+ The failure this exists for is not a wrong value, it is a STALE one, and it ships
7
+ silently. Extracting the tables and then fixing the extractor leaves the shipped
8
+ table describing the old source with no sign anywhere: `node-types.csv` carried an
9
+ empty `default_element_class` for three releases because the column's regex was
10
+ repaired after that run, and nothing compared the two again.
11
+
12
+ Row counts cannot catch it - the count was right the whole time - and neither can
13
+ the release gate, which never sees the plugin source: it is licensed third-party
14
+ code and is not in the repository. So this is a local step, and the right moment
15
+ for it is after ANY change to a tool under `tools/extract_*.py`, not only after a
16
+ plugin upgrade.
17
+ """
18
+ import filecmp
19
+ import os
20
+ import subprocess
21
+ import sys
22
+ import tempfile
23
+
24
+ EXTRACTORS = [
25
+ "extract_node_types.py",
26
+ "extract_style_properties.py",
27
+ "extract_placement.py",
28
+ "extract_default_children.py",
29
+ "extract_dynamic_variables.py",
30
+ "extract_interactions.py",
31
+ "extract_pluggables.py",
32
+ ]
33
+
34
+
35
+ def main():
36
+ if len(sys.argv) != 2:
37
+ sys.exit(__doc__)
38
+ plugin_root = sys.argv[1]
39
+ here = os.path.dirname(os.path.abspath(__file__))
40
+ data = os.path.join(here, "..", "data")
41
+ if not os.path.isdir(plugin_root):
42
+ sys.exit("no plugin source at %s" % plugin_root)
43
+
44
+ with tempfile.TemporaryDirectory() as tmp:
45
+ for name in EXTRACTORS:
46
+ r = subprocess.run([sys.executable, os.path.join(here, name),
47
+ plugin_root, tmp],
48
+ capture_output=True, text=True, encoding="utf-8",
49
+ errors="replace")
50
+ if r.returncode != 0:
51
+ sys.exit("%s failed:\n%s" % (name, (r.stdout + r.stderr)[-600:]))
52
+
53
+ stale, checked = [], 0
54
+ for fname in sorted(os.listdir(tmp)):
55
+ shipped = os.path.join(data, fname)
56
+ if not os.path.exists(shipped):
57
+ stale.append("%s: extracted but not shipped" % fname)
58
+ continue
59
+ checked += 1
60
+ if not filecmp.cmp(shipped, os.path.join(tmp, fname), shallow=False):
61
+ # name the columns that moved, not just the file
62
+ with open(shipped, encoding="utf-8") as a, \
63
+ open(os.path.join(tmp, fname), encoding="utf-8") as b:
64
+ al, bl = a.read().splitlines(), b.read().splitlines()
65
+ first = next((i for i, (x, y) in enumerate(zip(al, bl)) if x != y), None)
66
+ detail = ("%d lines -> %d" % (len(al), len(bl)) if len(al) != len(bl)
67
+ else "first difference at line %d:\n shipped: %s\n fresh : %s"
68
+ % (first + 1, al[first][:110], bl[first][:110]))
69
+ stale.append("%s: %s" % (fname, detail))
70
+
71
+ print("checked %d source-derived tables against a fresh extraction" % checked)
72
+ if stale:
73
+ print("\nSTALE:")
74
+ for s in stale:
75
+ print(" - %s" % s)
76
+ sys.exit(1)
77
+ print("every one matches")
78
+
79
+
80
+ if __name__ == "__main__":
81
+ main()