mosaic-headless 1.16.0 → 1.17.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 (30) hide show
  1. package/SKILL.md +38 -2
  2. package/assets/templates/platforms/claude-ai.json +1 -1
  3. package/assets/templates/platforms/claude-code.json +1 -1
  4. package/assets/templates/platforms/codex-cli.json +1 -1
  5. package/assets/templates/platforms/copilot.json +1 -1
  6. package/assets/templates/platforms/gemini-cli.json +1 -1
  7. package/bin/check-release.mjs +22 -0
  8. package/data/conversion-batch.csv +20 -0
  9. package/data/node-type-notes.csv +1 -1
  10. package/evals/accordion-is-not-broken/graders/criteria.md +24 -0
  11. package/evals/accordion-is-not-broken/graders/skill-fired.md +5 -0
  12. package/evals/accordion-is-not-broken/prompt.md +9 -0
  13. package/evals/build-simple-page/graders/criteria.md +23 -0
  14. package/evals/build-simple-page/graders/skill-fired.md +5 -0
  15. package/evals/build-simple-page/prompt.md +10 -0
  16. package/evals/code-node-renders-500/graders/criteria.md +23 -0
  17. package/evals/code-node-renders-500/graders/skill-fired.md +5 -0
  18. package/evals/code-node-renders-500/prompt.md +10 -0
  19. package/evals/grid-child-placement/graders/criteria.md +23 -0
  20. package/evals/grid-child-placement/graders/skill-fired.md +5 -0
  21. package/evals/grid-child-placement/prompt.md +8 -0
  22. package/evals/move-theme-safely/graders/criteria.md +21 -0
  23. package/evals/move-theme-safely/graders/skill-fired.md +5 -0
  24. package/evals/move-theme-safely/prompt.md +7 -0
  25. package/package.json +3 -2
  26. package/references/failure-modes.md +25 -0
  27. package/tools/build_page.py +20 -0
  28. package/tools/from_elementor.py +17 -0
  29. package/tools/sweep_node_types.py +9 -0
  30. package/tools/verify_conversion.py +13 -5
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 (122 node types, 181 properties, 98 style properties with 20 structured value shapes pinned down, 53 style states, 151 element classes, 74 dynamic variables, 12 interaction triggers, 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, 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 from outside the editor and holds the copy against the source tree for tree.
5
5
  license: "MIT"
6
6
  author: "moksa (https://moksaweb.com)"
7
- version: "1.16.0"
7
+ version: "1.17.0"
8
8
  ---
9
9
 
10
10
  # Headless Mosaic
@@ -73,8 +73,19 @@ right rule, wrong result HTTP 200, in the stylesheet, correct, and the BROWSE
73
73
  something you forgot, a font that cannot render the
74
74
  text, a property the layout mode overrides
75
75
  no template for the URL HTTP 406 with an EMPTY BODY for anyone not logged in
76
+ render-time fatal from HTTP 500 - the commit went through and the page dies
77
+ CONTENT when Mosaic parses it. A `code` node's content is a
78
+ template: `@media(` (as every minifier writes it) is
79
+ read as a function call and kills the whole page.
80
+ `@media (` renders. build_page refuses the former.
76
81
  ```
77
82
 
83
+ That last one also fooled every checker here for a day: WordPress answers a fatal
84
+ with a 2,697-byte error document, which is larger than the 2,000-byte "healthy"
85
+ floor, and `Client.page()` returned that body as if it were the page - so
86
+ `build_site` printed `OK 2645 bytes` over an HTTP 500. A 5xx now reads as an
87
+ empty page, which is what it is.
88
+
78
89
  **406 is Mosaic's "no template matched".** `FrontendRenderer` answers
79
90
  `TemplateNotFoundException` with `status_header(406)` and prints the explanation
80
91
  only for an admin, so a logged-out visitor gets a blank page and a status code
@@ -244,7 +255,12 @@ CONVERSION an Elementor page turned into a Mosaic spec and built, then checked
244
255
  none introduced by the conversion. That split is a check of its own,
245
256
  FIDELITY, and the inherited defects are recorded beside it as still
246
257
  real: a 3.19:1 label is 3.19:1 whoever wrote it. 8 of 8.
247
- data/conversion-verification.csv
258
+ Then all 19 pages of the site, each re-converted, built onto one
259
+ post, cache purged and content-checked in turn: 19 of 19, 3,281
260
+ elements carried and 11 declared. The one that did not pass the
261
+ first time was the HTTP 500 above - an "open source" page whose
262
+ `<style>` blocks wrote `@media(`. data/conversion-verification.csv,
263
+ data/conversion-batch.csv
248
264
 
249
265
  ACCORDION 7 checks that resolve a wrong entry in this skill's own tables.
250
266
  `accordion-item` and `accordion-content` are recorded BROKE_PAGE, which
@@ -283,6 +299,17 @@ BUILD nine complete designed pages built through the tables alone and che
283
299
  colour anywhere, plus a real studio homepage rebuilt from the
284
300
  live moksaweb.com. sites/_moksa.py is the one that ships.
285
301
 
302
+ EVAL the skill itself, put in front of the model with and without it loaded
303
+ (`claude plugin eval .`, 5 cases x 3 runs x 2 arms, three LLM judges
304
+ per run). Every case is a question a user of this skill would ask
305
+ where the measured facts decide the answer: an invented `heading`
306
+ type, a `code` node that 500s on `@media(`, whether the accordion is
307
+ usable, moving a theme without going live by accident, placing a grid
308
+ child on tablet only. With the skill: 1.00 on all five. Without it:
309
+ 0.00 on all five - and the baseline's best answer was to refuse ("I
310
+ don't have reliable knowledge of Mosaic Pro's spec format"), which is
311
+ the right thing for a model with no data to do. evals/
312
+
286
313
  MEASURED 114 REST routes, 151 element classes, 59 condition subjects,
287
314
  23 tables / 206 columns - read off the running site.
288
315
 
@@ -421,6 +448,7 @@ so the pattern is in the data, not just in this paragraph.
421
448
  | `data/loop-verification.csv` | 28 | **measured live** - a perpetual animation: periodicity by scrubbing a paused timeline, per-element occlusion of both text and controls at five widths, and the enlarged view opened by pointer and by keyboard |
422
449
  | `data/accordion-verification.csv` | 7 | **driven live** - the accordion family nested the way its factory requires, against the guard that refuses it unparented. Resolves two BROKE_PAGE rows |
423
450
  | `data/conversion-verification.csv` | 8 | **converted then checked live** - an Elementor page rebuilt as Mosaic and held against its source (text, images, links, heading levels), then put through rwd, browser and the design audit with every finding classified inherited-or-introduced |
451
+ | `data/conversion-batch.csv` | 19 | **converted, built and checked live, one page after another** - every Elementor page of a production site through the converter, with per-page element and content counts |
424
452
  | `data/theme-zip-verification.csv` | 22 | **round-tripped live** - Mosaic's own ZIP export imported in test mode and compared to its source, table by table and tree by tree |
425
453
  | `data/node-type-notes.csv` | 8 | where a sweep outcome is true but misleading on its own, why. Surfaced by `mo.py type` |
426
454
  | `data/interaction-verification.csv` | 7 | **probed live** - interaction animation shapes, with negative controls and the stored row beside the payload |
@@ -559,6 +587,11 @@ post — `build_all.py` resets first for that reason.
559
587
  whose spec set neither; between a masthead and the block below it that is a
560
588
  160px hole. Set `paddingTop`/`paddingBottom` explicitly when a section's rhythm
561
589
  is meant to come from its contents.
590
+ - **A `code` node's content is a template, and `@media(` is a function call.**
591
+ Mosaic parses `code` content for `@VAR(...)`; a CSS at-rule glued to its
592
+ parenthesis - the form every minifier emits - is parsed as a call with
593
+ unparseable arguments and the page renders as HTTP 500, after a clean commit.
594
+ Write `@media (`. Text nodes are not parsed and are safe; measured both.
562
595
  - **The front page 301s.** When a post is `page_on_front`, its own permalink
563
596
  (`/moksa/`) redirects to `/`; a fetch that does not follow redirects reads 0
564
597
  bytes and looks like an outage.
@@ -656,6 +689,9 @@ wp eval-file tools/theme_zip_compare.php <source> <copy> theme-zip-verification.
656
689
  wp eval-file tools/theme_delete.php <copy>
657
690
  python tools/probe.py --config lab.json --cases cases.json # ad-hoc measurement
658
691
  python tools/check_placement_predicts.py
692
+ claude plugin eval . --runs 3 -j 3 --no-publish # the skill vs no skill, 5 cases; Bash is
693
+ # not granted (no sandbox on Windows), the
694
+ # agent reads data/ instead of running mo.py
659
695
  ```
660
696
 
661
697
  `bootstrap_probe_theme.php` exists because Mosaic's own new-theme flow calls
@@ -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.16.0"
23
+ "version": "1.17.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.16.0"
23
+ "version": "1.17.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.16.0"
23
+ "version": "1.17.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.16.0"
23
+ "version": "1.17.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.16.0"
23
+ "version": "1.17.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,
@@ -183,6 +183,28 @@ if (/,FAIL,/.test(conv)) fail("data/conversion-verification.csv carries a failed
183
183
  if (/^IMAGES,PASS,"?0 of 0/m.test(conv))
184
184
  fail("conversion-verification.csv: IMAGES passed vacuously on a page with no images");
185
185
 
186
+ // The batch table: every page of the site through the converter. One FAIL row
187
+ // means the converter regressed on a real page, which the single-page table can
188
+ // hide by simply being run on the page that still works.
189
+ const batch = read("data/conversion-batch.csv");
190
+ if (!batch.startsWith("elementor_post,converted,skipped,result"))
191
+ fail("data/conversion-batch.csv is not the batch table");
192
+ if (/,FAIL,/.test(batch)) fail("data/conversion-batch.csv carries a page that did not pass");
193
+ if ((batch.match(/,PASS,/g) || []).length < 10)
194
+ fail("data/conversion-batch.csv has fewer than 10 passing pages - the batch was not run");
195
+
196
+ // The eval suite ships with the skill. Five cases with an LLM criteria grader each
197
+ // is the floor; a suite that has quietly lost its cases would let the "1.00 vs 0.00"
198
+ // claim in SKILL.md go stale.
199
+ {
200
+ const cases = fs.readdirSync(path.join(ROOT, "evals"), { withFileTypes: true })
201
+ .filter(d => d.isDirectory() && d.name !== "results").map(d => d.name);
202
+ if (cases.length < 5) fail(`evals/ has ${cases.length} cases, SKILL.md claims five`);
203
+ for (const c of cases)
204
+ if (!fs.existsSync(path.join(ROOT, "evals", c, "graders", "criteria.md")))
205
+ fail(`evals/${c} has no graders/criteria.md`);
206
+ }
207
+
186
208
  const audit = read("data/design-audit.csv");
187
209
  if (!audit.startsWith("url,breakpoints,check,level"))
188
210
  fail("data/design-audit.csv is not the audit table verify_browser.py writes");
@@ -0,0 +1,20 @@
1
+ elementor_post,converted,skipped,result,checks,text,images,links
2
+ 1296,87,0,PASS,5/5,35 of 35 source strings,0 of 0 source images,2 of 2 source link targets
3
+ 1628,29,1,PASS,5/5,9 of 9 source strings,0 of 0 source images,0 of 0 source link targets
4
+ 2339,252,1,PASS,5/5,104 of 104 source strings,11 of 11 source images,26 of 26 source link targets
5
+ 2360,254,0,PASS,5/5,113 of 113 source strings,16 of 16 source images,21 of 21 source link targets
6
+ 2361,369,1,PASS,5/5,190 of 190 source strings,0 of 0 source images,11 of 11 source link targets
7
+ 2604,453,2,PASS,5/5,301 of 301 source strings,0 of 0 source images,1 of 1 source link targets
8
+ 2760,8,4,PASS,5/5,3 of 3 source strings,0 of 0 source images,0 of 0 source link targets
9
+ 3257,135,0,PASS,5/5,58 of 58 source strings,0 of 0 source images,2 of 2 source link targets
10
+ 3455,92,1,PASS,5/5,32 of 32 source strings,0 of 0 source images,2 of 2 source link targets
11
+ 9131,156,0,PASS,5/5,72 of 72 source strings,0 of 0 source images,4 of 4 source link targets
12
+ 9135,154,0,PASS,5/5,66 of 66 source strings,0 of 0 source images,4 of 4 source link targets
13
+ 9139,184,1,PASS,5/5,85 of 85 source strings,0 of 0 source images,2 of 2 source link targets
14
+ 9145,150,0,PASS,5/5,64 of 64 source strings,0 of 0 source images,5 of 5 source link targets
15
+ 9146,155,0,PASS,5/5,65 of 65 source strings,0 of 0 source images,5 of 5 source link targets
16
+ 9147,152,0,PASS,5/5,61 of 61 source strings,0 of 0 source images,4 of 4 source link targets
17
+ 9148,154,0,PASS,5/5,61 of 61 source strings,0 of 0 source images,4 of 4 source link targets
18
+ 9149,154,0,PASS,5/5,65 of 65 source strings,0 of 0 source images,4 of 4 source link targets
19
+ 9151,147,0,PASS,5/5,61 of 61 source strings,0 of 0 source images,6 of 6 source link targets
20
+ 9152,196,0,PASS,5/5,112 of 112 source strings,0 of 0 source images,4 of 4 source link targets
@@ -2,7 +2,7 @@ type,note
2
2
  accordion-content,"Same as accordion-item: BROKE_PAGE unparented, fine under an accordion-item. Note that accordion-item declares only accordion-item and accordion-loop-items as allowed children - title and content arrive through heal(), which is why the placement guard permits them."
3
3
  accordion-item,"Swept as BROKE_PAGE, which is what it does under a plain container. The factory needs an `accordion` above it - the failure reads `AccordionElementMResource instance required`. Nested as accordion > accordion-item > (accordion-title, accordion-content) it commits and renders; see tools/probe_accordion.py."
4
4
  accordion-title,"Renders as a <dt> carrying tabindex=0 and aria-expanded, and the plugin toggles M_EL_AccordionItem--opened on the item. That is a keyboard-operable disclosure control for free; styling the ITEM off that class needs no :has()."
5
- code,"`processShortcodes` is NO_EFFECT in the property sweep because the sweep's content carried no shortcode - there was nothing for the flag to act on. With `[woocommerce_my_account]` and processShortcodes ""1"" the node renders the whole WooCommerce account UI (sites/_moksa.py, the my-account page). The flag works; the row records the sweep, not the property."
5
+ code,"`processShortcodes` is NO_EFFECT in the property sweep because the sweep's content carried no shortcode - there was nothing for the flag to act on. With `[woocommerce_my_account]` and processShortcodes ""1"" the node renders the whole WooCommerce account UI (sites/_moksa.py, the my-account page). The flag works; the row records the sweep, not the property. Its content is parsed as a template: a CSS at-rule glued to `(` - `@media(` - is read as a function call and the page renders as HTTP 500 after a clean commit. Write `@media (`; build_page refuses the glued form. Text nodes are not parsed."
6
6
  component-instance,"Swept as COMMIT_500, which is what a BARE one does. The node's type is not `component-instance` but `component-instance/<componentID>` - the factory splits on that slash and a bare one has no id to read. With a real component it commits and renders; see sweep_components.py."
7
7
  component-internal,"The component's content hangs under THIS node, in the `node/component/<id>` key of a componentDocumentInstance. `component-root` looks like the parent and accepts no children."
8
8
  component-root,"Accepts no children. It frames the component's preview document, not its content."
@@ -0,0 +1,24 @@
1
+ ---
2
+ type: llm
3
+ weight: 1
4
+ ---
5
+
6
+ The right answer corrects the premise using what the skill measured: the sweep
7
+ committed every type alone under a plain div, and accordion-item / accordion-content
8
+ broke there because they were committed WITHOUT the parent their factory requires
9
+ (the error strings name the missing parent). Nested properly -
10
+ accordion > accordion-item > (accordion-title, accordion-content) - they commit,
11
+ render as <dl><div class=M_EL_AccordionItem><dt tabindex=0 aria-expanded><dd>, and
12
+ give keyboard operation and a screen-reader state for free.
13
+
14
+ Score highly when the answer:
15
+ 1. Says the accordion IS usable and explains the BROKE_PAGE row as a parent artefact
16
+ of the sweep method, not a verdict on the type.
17
+ 2. Gives the correct nesting, with accordion-title and accordion-content under
18
+ accordion-item.
19
+ 3. Recommends the native accordion over hand-rolled divs for an FAQ, ideally
20
+ mentioning the accessibility it provides (tabindex/aria-expanded) or that
21
+ tools/probe_accordion.py proved it.
22
+
23
+ Score 0.5 if it recommends the native accordion but does not explain the nesting or
24
+ why the table says BROKE_PAGE. Score 0 if it agrees the accordion should be avoided.
@@ -0,0 +1,5 @@
1
+ ---
2
+ type: tool_used
3
+ tool: Skill
4
+ weight: 0.01
5
+ ---
@@ -0,0 +1,9 @@
1
+ ---
2
+ max_turns: 20
3
+ allowed_tools: [Read, Glob, Grep, Skill]
4
+ ---
5
+
6
+ Your own data says `accordion-content` and `accordion-item` are BROKE_PAGE -
7
+ committing one turned the public page into a 54-byte error string. So I should avoid
8
+ Mosaic's accordion entirely and build a disclosure widget from divs, right? I need an
9
+ expandable FAQ section.
@@ -0,0 +1,23 @@
1
+ ---
2
+ type: llm
3
+ weight: 1
4
+ ---
5
+
6
+ A successful answer builds the page from Mosaic's REAL data model rather than from
7
+ guesswork. Score highly when ALL of the following hold:
8
+
9
+ 1. The heading is a `text` node with `tagName: "h1"` - NOT an invented `heading`
10
+ node type. (Mosaic has no heading type; `text` renders as <div> unless tagName is set.)
11
+ 2. The paragraph is a `text` node with `tagName: "p"`.
12
+ 3. The button is a `button` node whose `url` is set to /contact/. Bonus: the answer
13
+ notes that `button` renders as <span> without a url and as <a href> with one, or
14
+ that target/rel do nothing without a url.
15
+ 4. Every node type used is one of Mosaic's real types (div, section, text, button,
16
+ image, code, menu-link ...). Any node type that does not exist in Mosaic
17
+ (e.g. "heading", "paragraph", "container", "column") is a failure.
18
+ 5. The answer consulted the skill's data before writing - it ran `mo.py` or read
19
+ data/ files, or explicitly cites the skill's tables - rather than answering from
20
+ memory.
21
+
22
+ Score 0 if the spec invents node types or properties. Score 0.5 if the types are
23
+ right but the answer never checked them against the skill's data.
@@ -0,0 +1,5 @@
1
+ ---
2
+ type: tool_used
3
+ tool: Skill
4
+ weight: 0.01
5
+ ---
@@ -0,0 +1,10 @@
1
+ ---
2
+ max_turns: 30
3
+ allowed_tools: [Read, Glob, Grep, Skill]
4
+ ---
5
+
6
+ I run a WordPress site on Mosaic Pro. Write me a Mosaic page spec (the JSON that
7
+ tools/build_page.py takes) for a landing section with: an H1 that says "Ship faster",
8
+ one paragraph of body copy, and a call-to-action button that links to /contact/ and
9
+ opens in the same tab. Keep it minimal, but every node type and property must be
10
+ one that actually works.
@@ -0,0 +1,23 @@
1
+ ---
2
+ type: llm
3
+ weight: 1
4
+ ---
5
+
6
+ The correct diagnosis is specific and measured: a `code` node's content is parsed
7
+ by Mosaic's templating parser (the one that evaluates @VAR(...) expressions), and a
8
+ CSS at-rule written WITHOUT a space before its parenthesis - `@media(` - is read as
9
+ a call to a function named `media`, whose arguments the parser cannot tokenise, so
10
+ rendering fatals (Parser::matchOperator TypeError) after a clean commit.
11
+
12
+ Score highly when the answer:
13
+ 1. Names `@media(` (the glued at-rule) as the cause - not the stylesheet in general,
14
+ not a syntax error, not a plugin conflict.
15
+ 2. Gives the fix: write `@media (` with a space (or otherwise keep at-rules from
16
+ being parsed as calls).
17
+ 3. Explains why the commit succeeded anyway: the failure is at render time, not at
18
+ validation time - Mosaic has several failure modes that never change the commit's
19
+ HTTP status.
20
+
21
+ Score 0.5 if it correctly suspects the @ but proposes something vague (e.g. "escape
22
+ special characters"). Score 0 if it blames caching, PHP memory, the theme, or tells
23
+ the user to check error logs without identifying the cause.
@@ -0,0 +1,5 @@
1
+ ---
2
+ type: tool_used
3
+ tool: Skill
4
+ weight: 0.01
5
+ ---
@@ -0,0 +1,10 @@
1
+ ---
2
+ max_turns: 20
3
+ allowed_tools: [Read, Glob, Grep, Skill]
4
+ ---
5
+
6
+ I committed a Mosaic `code` node containing a minified stylesheet - the commit
7
+ returned HTTP 200 with every revision accepted - and now the whole public page
8
+ returns HTTP 500 with WordPress's "critical error" screen. The stylesheet starts
9
+ with `<style>.hero{color:#fff}@media(max-width:767px){.hero{font-size:24px}}</style>`.
10
+ The commit looks fine. What is wrong and how do I fix it?
@@ -0,0 +1,23 @@
1
+ ---
2
+ type: llm
3
+ weight: 1
4
+ ---
5
+
6
+ The measured facts that decide this: Mosaic's gridColumnStart / gridColumnEnd /
7
+ gridRowStart / gridRowEnd properties exist in the style table but EMIT NOTHING (16
8
+ declarations committed, zero grid-column rules in the delivered CSS), so the
9
+ documented way to place a grid child is `customStyles` with a raw
10
+ `grid-column: 1 / 3;` declaration. And Mosaic's responsive axis is
11
+ states["&"][breakpoint][property] with `_` desktop, `_t` <=1079px, `_m` <=767px -
12
+ `_t` is a min/max band, so a tablet-only value goes under `_t` and, because `_m`
13
+ inherits from `_t`, must be reset under `_m`.
14
+
15
+ Score highly when the answer:
16
+ 1. Does NOT recommend gridColumnStart/gridColumnEnd as the solution (or explicitly
17
+ says they are inert and why).
18
+ 2. Uses customStyles with grid-column (or grid-area) for the placement.
19
+ 3. Puts the value under the `_t` breakpoint and resets it under `_m` (or explains
20
+ that breakpoints cascade downward so mobile must be handled).
21
+
22
+ Score 0.5 if it uses customStyles correctly but ignores the breakpoint cascade.
23
+ Score 0 if it recommends gridColumnStart or invents a breakpoint name.
@@ -0,0 +1,5 @@
1
+ ---
2
+ type: tool_used
3
+ tool: Skill
4
+ weight: 0.01
5
+ ---
@@ -0,0 +1,8 @@
1
+ ---
2
+ max_turns: 20
3
+ allowed_tools: [Read, Glob, Grep, Skill]
4
+ ---
5
+
6
+ In my Mosaic page I have a CSS grid container and I want one child to span the
7
+ first two columns on tablet only (not on desktop, not on mobile). Which style
8
+ properties do I set?
@@ -0,0 +1,21 @@
1
+ ---
2
+ type: llm
3
+ weight: 1
4
+ ---
5
+
6
+ The skill documents two paths and one serious trap. Score highly when the answer:
7
+
8
+ 1. Names at least one real path from the skill: `theme_export.php` /
9
+ `theme_import.php` (JSON rows over WP-CLI, ids kept because every scoped table has
10
+ a composite (themeID, ID) primary key) and/or `theme_zip.py` driving Mosaic's own
11
+ ZIP export/import over its milestone protocol.
12
+ 2. States the trap: Mosaic's native import DEFAULTS TO ACTIVATING the imported theme
13
+ as the live site (themeActivateMode falls back to 'live'), and `replaceThemeID`
14
+ deletes the theme it names - so the import should be run in test mode first
15
+ (theme_zip.py does this unless told --activate).
16
+ 3. Mentions that a whole theme moves, never a single page, and/or that a tree-walking
17
+ import drops orphan nodes that a row-copy carries.
18
+
19
+ Score 0.5 if it gives a real path but omits the live-activation trap. Score 0 if it
20
+ suggests copying the database or the theme folder by hand, or invents an
21
+ export feature that the skill does not document.
@@ -0,0 +1,5 @@
1
+ ---
2
+ type: tool_used
3
+ tool: Skill
4
+ weight: 0.01
5
+ ---
@@ -0,0 +1,7 @@
1
+ ---
2
+ max_turns: 20
3
+ allowed_tools: [Read, Glob, Grep, Skill]
4
+ ---
5
+
6
+ I have a Mosaic theme on a staging install and I want to move it to production.
7
+ What are my options, and what could go wrong?
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mosaic-headless",
3
- "version": "1.16.0",
3
+ "version": "1.17.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",
@@ -43,7 +43,8 @@
43
43
  "README.zh-TW.md",
44
44
  "README.ja.md",
45
45
  "README.ko.md",
46
- "LICENSE"
46
+ "LICENSE",
47
+ "evals/"
47
48
  ],
48
49
  "engines": {
49
50
  "node": ">=18"
@@ -269,3 +269,28 @@ python tools/sweep_node_types.py --config sweep.json --sweep
269
269
  ```
270
270
 
271
271
  The sweep is destructive by design and must only be pointed at a scratch site.
272
+
273
+ ## Render-time fatal from content: HTTP 500 after a clean commit
274
+
275
+ A `code` node's content is run through Mosaic's templating parser at render, which
276
+ is how `@VAR('post/title')` works inside raw HTML. It also means a CSS at-rule
277
+ written without a space - `@media(max-width:767px)`, which is how every minifier
278
+ writes it - is read as a call to a function named `media` with arguments the
279
+ parser cannot tokenise:
280
+
281
+ ```
282
+ commit -> HTTP 200, 238 nodes, syncResponseEnvelopes all "create"
283
+ render -> HTTP 500. PHP Fatal: Parser::matchOperator(): Argument #1 ($token)
284
+ must be of type Token, null given (TemplatingParser/Parser.php:151)
285
+ ```
286
+
287
+ Measured on the demo site with each form alone on a page: `@media (` renders,
288
+ `@media(` does not; `@supports(` the same. Text nodes are NOT parsed - a paragraph
289
+ containing `@media(max-width:1px)` or `info@example.com` renders literally - so the
290
+ trap is scoped to `code`. `build_page.py` refuses a `code` node whose content
291
+ matches a glued at-rule, and `from_elementor.py` inserts the space and reports it.
292
+
293
+ The other half of this finding is about the checkers: WordPress's "critical error"
294
+ page is 2,697 bytes, above the 2,000-byte healthy floor, and `Client.page()` was
295
+ returning an HTTPError's body as the page - so `build_site` announced `OK` over a
296
+ 500 and a whole batch believed it. A 5xx now returns an empty body.
@@ -43,6 +43,7 @@ import argparse
43
43
  import csv
44
44
  import json
45
45
  import os
46
+ import re
46
47
  import sys
47
48
  import uuid
48
49
 
@@ -64,6 +65,11 @@ def load_csv(name):
64
65
  return list(csv.DictReader(fh))
65
66
 
66
67
 
68
+ AT_RULE_GLUED = re.compile(
69
+ r"@(media|supports|container|layer|scope|import|page|starting-style|"
70
+ r"font-feature-values|counter-style|property|document)\(")
71
+
72
+
67
73
  class Surface:
68
74
  """Everything the builder is allowed to know, loaded from the skill's own tables."""
69
75
 
@@ -90,6 +96,20 @@ class Surface:
90
96
  if t not in self.types:
91
97
  problems.append("unknown node type %r" % t)
92
98
  return problems
99
+ # A `code` node's content is parsed as a Mosaic template at render time,
100
+ # and a CSS at-rule glued to its parenthesis - `@media(`, the way every
101
+ # minifier writes it - is read as a function call whose arguments cannot
102
+ # be parsed. The commit succeeds; the public page becomes an HTTP 500.
103
+ # Measured both ways on the demo site (`@media (` renders, `@media(`
104
+ # does not). Refused here for the same reason an unsafe type is: the
105
+ # failure is silent at the only point this tool can still stop it.
106
+ if t == "code" and not force:
107
+ content = (node.get("data") or {}).get("content") or ""
108
+ hit = AT_RULE_GLUED.search(content)
109
+ if hit:
110
+ problems.append("code content contains `%s` - Mosaic's template parser reads "
111
+ "it as a function call and the page renders as HTTP 500; "
112
+ "write `%s (`" % (hit.group(0), hit.group(0)[:-1]))
93
113
  # node-verification.csv measured every type UNDER A PLAIN DIV. A type that broke
94
114
  # there is not broken in general - most of them are family members that simply
95
115
  # need their own parent. So the measured verdict only applies when the parent is
@@ -281,10 +281,27 @@ def as_button(el, ctx):
281
281
  return styled(node, s, extra)
282
282
 
283
283
 
284
+ # CSS at-rules that minifiers glue to their parenthesis. Mosaic parses a `code`
285
+ # node's content as a template - `@VAR(...)`, `@concat(...)` - and reads
286
+ # `@media(` as a function call whose arguments it then cannot parse:
287
+ # TypeError: Parser::matchOperator(): Argument #1 must be Token, null given
288
+ # at RENDER time, as an HTTP 500 for the whole page, after a commit that went
289
+ # through cleanly. `@media (` with the space is left alone. Measured on the
290
+ # demo site, both forms, alone on a page; text nodes are not parsed and are safe.
291
+ AT_RULE_GLUED = re.compile(
292
+ r"@(media|supports|container|layer|scope|import|page|starting-style|"
293
+ r"font-feature-values|counter-style|property|document)\(")
294
+
295
+
284
296
  @handler("html", "shortcode")
285
297
  def as_html(el, ctx):
286
298
  s = el.get("settings") or {}
287
299
  raw = s.get("html") or s.get("shortcode") or ""
300
+ fixed, n = AT_RULE_GLUED.subn(lambda m: "@%s (" % m.group(1), raw)
301
+ if n:
302
+ ctx.note(el, "%d CSS at-rule(s) written as `@media(` given the space Mosaic's "
303
+ "template parser needs - `@media(` renders as HTTP 500" % n)
304
+ raw = fixed
288
305
  return {"type": "code",
289
306
  "data": {"attrID": ctx.attr(el), "insertLocation": "inPlace",
290
307
  "content": raw,
@@ -85,6 +85,15 @@ class Client:
85
85
  with urllib.request.urlopen(req, timeout=120) as r:
86
86
  return r.read().decode("utf-8", "replace")
87
87
  except urllib.error.HTTPError as e:
88
+ # A 5xx is not a page. WordPress answers a render-time PHP fatal with a
89
+ # 2,697-byte "critical error" document, which is LARGER than
90
+ # MIN_HEALTHY_BYTES - so returning that body made build_site print
91
+ # "OK 2645 bytes" over a dead page, and probe.py call it healthy. An
92
+ # empty string is what every byte-based check downstream already
93
+ # treats as broken. 4xx bodies are kept: a 404 page is real content
94
+ # and a 406 has none.
95
+ if e.code >= 500:
96
+ return ""
88
97
  return e.read().decode("utf-8", "replace")
89
98
 
90
99
 
@@ -50,12 +50,20 @@ def text_of(v):
50
50
  return re.sub(r"\s+", " ", t).strip()
51
51
 
52
52
 
53
- def harvest(tree):
54
- """Every piece of content the source page claims to have."""
53
+ def harvest(tree, skip_ids=frozenset()):
54
+ """Every piece of content the source page claims to have.
55
+
56
+ `skip_ids` are the elements the converter's report declared SKIPPED. Their
57
+ whole subtree is left out here, because the converter already said, in
58
+ writing and with a reason, that it did not carry them - counting their text
59
+ as lost a second time would make the report look worse the more honest the
60
+ converter was. They are still shown, under `declared`."""
55
61
  texts, images, links, headings, skipped_kinds = [], [], [], [], []
56
62
 
57
63
  def walk(els):
58
64
  for e in els:
65
+ if e.get("id") in skip_ids:
66
+ continue
59
67
  s = e.get("settings") or {}
60
68
  kind = e.get("widgetType") or e.get("elType")
61
69
  for k in ("title", "text", "button_text", "editor"):
@@ -131,13 +139,13 @@ def main():
131
139
  tree = json.loads(open(a.data, encoding="utf-8").read())
132
140
  if isinstance(tree, dict):
133
141
  tree = tree.get("content") or tree.get("elements") or [tree]
134
- texts, images, links, headings, _ = harvest(tree)
135
-
136
- declared = []
142
+ declared, skip_ids = [], set()
137
143
  if a.report:
138
144
  for r in csv.DictReader(open(a.report, encoding="utf-8")):
139
145
  if r["result"] == "SKIPPED":
140
146
  declared.append((r["kind"], r["detail"]))
147
+ skip_ids.add(r["elementor_id"])
148
+ texts, images, links, headings, _ = harvest(tree, frozenset(skip_ids))
141
149
 
142
150
  with sync_playwright() as pw:
143
151
  b = pw.chromium.launch()