mosaic-headless 1.21.0 → 1.21.1

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/README.ja.md CHANGED
@@ -161,7 +161,7 @@ commit の成功はページが動く証拠ではないし、正しいスタイ
161
161
  | **ライブ計測** | REST ルート 115、variant 156、条件サブジェクト 59、23 テーブル / 210 カラム |
162
162
  | **1.0.9 の新機能** | modal(`<dialog>`)、OpenStreetMap、exit-intent と scroll-depth のトリガー、run rules、memory アクション、modal ショートハンド — すべて実ページに作り、ブラウザで実際に操作:19 / 19。「離脱時ポップアップを 1 セッション 1 回」が配線なしのオブジェクト 1 つで済む |
163
163
  | **slider** | ファクトリが要求する入れ子でブラウザ実操作、19 / 19:宣言どおりの間隔で自走、矢印・ドット・キーボードで駆動、`prefers-reduced-motion` では自ら停止。ドットは実はテンプレートで、スライド数だけ複製され id は全部同じ |
164
- | **本物のサイト** | 変換済み 19 ページを 1 つのサイトに:実メニューとページごとのモーダル。ナビ 27 / 27、モーダル 19 / 19、トリガー/ルール/記憶/閉じ方の組み合わせは 6 種 |
164
+ | **本物のサイト** | 変換済み 19 ページを 1 つのサイトに:実メニューとページごとのモーダル。ナビ 46 / 46、モーダル 19 / 19、トリガー/ルール/記憶/閉じ方の組み合わせは 6 種 |
165
165
  | **カスタムフィールド** | ACF と Meta Box、1 ページに 40 フィールド、`@VAR` / `@LOOP` で配信 HTML から読み戻し:62 のうち 59 が解決、3 つの空は理由付き;複数値フィールドのループはフィールドの行数どおりに描画。`tools/list_fields.php` が Mosaic の実際の登録名を出す |
166
166
  | **ライブ更新** | 1.0.7 → 1.0.8 をプラグイン自身の milestone ルートで wp-admin の外から駆動:6 milestone、4 テーブル改名、出力クラス名は全部変わり、`customStyles` は全部書き換え — その結果に対して上の計測をすべて再実行 |
167
167
 
package/README.ko.md CHANGED
@@ -157,7 +157,7 @@ commit 성공은 페이지가 동작한다는 증거가 아니고, 올바른 스
157
157
  | **라이브 측정** | REST 라우트 115, variant 156, 조건 subject 59, 23 테이블 / 210 컬럼 |
158
158
  | **1.0.9 신기능** | modal(`<dialog>`), OpenStreetMap, exit-intent·scroll-depth 트리거, run rules, memory 액션, modal 숏핸드를 실제 페이지에 만들고 브라우저에서 직접 조작: 19/19. "이탈 의도 팝업, 세션당 한 번"이 배선 없이 객체 하나 |
159
159
  | **slider** | 팩토리가 요구하는 중첩으로 브라우저에서 실제 조작, 19/19: 선언한 간격으로 자동 전진, 화살표·불릿·키보드로 구동, `prefers-reduced-motion`에서 스스로 멈춤. 불릿은 사실 템플릿이라 슬라이드 수만큼 복제되고 id가 전부 같다 |
160
- | **진짜 사이트** | 변환된 19개 페이지를 하나의 사이트로: 실제 메뉴와 페이지별 모달. 내비 27/27, 모달 19/19, 트리거·규칙·기억·닫기 조합 6종 |
160
+ | **진짜 사이트** | 변환된 19개 페이지를 하나의 사이트로: 실제 메뉴와 페이지별 모달. 내비 46/46, 모달 19/19, 트리거·규칙·기억·닫기 조합 6종 |
161
161
  | **커스텀 필드** | ACF와 Meta Box, 한 페이지에 40개 필드, `@VAR` / `@LOOP`로 전달된 HTML에서 읽어냄: 62개 중 59개 해석, 빈 3개는 이유 있음; 다중값 필드 루프는 필드의 행 수 그대로 렌더링. `tools/list_fields.php`가 Mosaic이 실제 등록하는 이름을 출력 |
162
162
  | **라이브 업그레이드** | 1.0.7 → 1.0.8을 플러그인 자체 milestone 라우트로 wp-admin 밖에서 구동: 6 milestone, 테이블 4개 이름 변경, 출력 클래스 이름 전부 변경, `customStyles` 전부 재작성 — 그 결과 위에서 위의 측정을 전부 다시 실행 |
163
163
 
package/README.md CHANGED
@@ -205,7 +205,7 @@ factories, so Pro types register and render regardless.
205
205
  | **measured live** | 115 REST routes, 156 variants, 59 condition subjects, 23 tables / 210 columns |
206
206
  | **1.0.9 features** | the modal (`<dialog>`), OpenStreetMap, the exit-intent and scroll-depth triggers, run rules, memory actions and the modal shorthand, each built on a live page and exercised in a real browser: 19 of 19. An exit-intent popup that fires once a session is one object, no wiring |
207
207
  | **slider** | the whole family nested as its factories require and driven in a browser, 19 of 19: advances on its own delay, arrows and bullets and the keyboard move it, holds still under `prefers-reduced-motion`. The bullet turns out to be a template repeated per slide - same id on every copy |
208
- | **a real site** | the nineteen converted Elementor pages assembled into one site with a real menu and a modal per page: 27 of 27 navigation checks, 19 of 19 modals, six distinct trigger / run-rule / memory / dismissal configurations |
208
+ | **a real site** | the nineteen converted Elementor pages assembled into one site with a real menu and a modal per page: 46 of 46 navigation checks, 19 of 19 modals, six distinct trigger / run-rule / memory / dismissal configurations |
209
209
  | **custom fields** | ACF and Meta Box, forty fields on a page, read back through `@VAR` / `@LOOP` off the delivered HTML: 59 of 62 resolve, 3 empties explained; loops over multi-value fields rendered exactly the field's rows. `tools/list_fields.php` prints the names Mosaic actually registers |
210
210
  | **upgraded live** | 1.0.7 -> 1.0.8 over the plugin's own milestone route, from outside wp-admin: 6 milestones, four tables renamed, every emitted class name changed, every `customStyles` rewritten - then every sweep above re-run on the result |
211
211
 
package/README.zh-TW.md CHANGED
@@ -182,7 +182,7 @@ commit 成功不代表頁面能用,樣式表正確也不代表。這裡每個
182
182
  | **線上量測** | 115 條 REST 路由、156 個 variant、59 個條件主體、23 張表 / 210 個欄位 |
183
183
  | **1.0.9 新功能** | modal(`<dialog>`)、OpenStreetMap、exit-intent 與 scroll-depth 觸發、run rules、memory action 與 modal shorthand,全部建在真實頁面上並用瀏覽器實際操作:19 之 19。「離開意圖彈窗、每個 session 只出現一次」只需要一個物件,不用接線 |
184
184
  | **slider** | 整組照工廠要求嵌套後在瀏覽器裡實際操作,19 之 19:會自己按宣告的間隔前進、箭頭與圓點與鍵盤都能驅動、`prefers-reduced-motion` 下會自己停。圓點其實是樣板,依 slide 數複製,每個複製品 id 相同 |
185
- | **真正的網站** | 19 個轉換頁組成一個站:真實選單、每頁一個彈窗。導覽 27 之 27、彈窗 19 之 19,六種不同的觸發/規則/記憶/關閉設定 |
185
+ | **真正的網站** | 19 個轉換頁組成一個站:真實選單、每頁一個彈窗。導覽 46 之 46、彈窗 19 之 19,六種不同的觸發/規則/記憶/關閉設定 |
186
186
  | **自訂欄位** | ACF 與 Meta Box,一頁四十個欄位,透過 `@VAR` / `@LOOP` 從送達的 HTML 讀回:62 個裡 59 個解析正確、3 個空值有解釋;多值欄位的迴圈剛好渲染出欄位的列數。`tools/list_fields.php` 列出 Mosaic 真正註冊的名字 |
187
187
  | **線上升級** | 1.0.7 → 1.0.8 走外掛自己的 milestone 路由、在 wp-admin 之外驅動:6 個 milestone、四張表改名、每個輸出 class 名稱都變、每個 `customStyles` 都重寫——然後上面每一項掃描在結果上重跑 |
188
188
 
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.0"
7
+ version: "1.21.1"
8
8
  ---
9
9
 
10
10
  # Headless Mosaic
@@ -190,7 +190,7 @@ SITE the nineteen converted Elementor pages assembled into one real site
190
190
  <a href>, a WordPress page each, and a modal on every page - six
191
191
  distinct configurations across pageLoad / exitIntent / scrollDepth,
192
192
  cap / cooldown / firstRunWindow, page / session / forever memory and
193
- four closedby policies. 27 of 27 navigation checks, 19 of 19 pages
193
+ four closedby policies. 46 of 46 navigation checks (including no page scrolling sideways at three widths), 19 of 19 pages
194
194
  carrying a working modal. data/navigation-verification.csv,
195
195
  data/page-modal-verification.csv
196
196
 
@@ -530,7 +530,7 @@ so the pattern is in the data, not just in this paragraph.
530
530
  | `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 |
531
531
  | `data/token-benchmark.csv` | 6 | **measured with tiktoken** - the same six lookups priced three ways: reading the plugin source, loading every table, querying `mo.py`. 71-99.5% fewer tokens than the source and 99.6%+ fewer than the tables, which total 259,539 - never load them, query them |
532
532
  | `data/slider-verification.csv` | 19 | **driven live** - two sliders, one autoplaying and one a carousel: advance, arrows, bullets, keyboard, reduced motion |
533
- | `data/navigation-verification.csv` | 27 | **fetched live** - every menu item is a real `<a href>` and every page of the nineteen answers 200 |
533
+ | `data/navigation-verification.csv` | 46 | **fetched and opened live** - every menu item is a real `<a href>`, every page of the nineteen answers 200, and no page scrolls sideways at 390 / 768 / 1280 |
534
534
  | `data/page-modal-verification.csv` | 19 | **read live** - one modal per page, and what each one's trigger, run rule, memory scope and dismissal policy actually came out as |
535
535
  | `data/dialog-verification.csv` | 19 | **driven live** - the 1.0.9 modal, its triggers, its run rules and the memory actions, each exercised in a real browser |
536
536
  | `data/custom-fields-verification.csv` | 62 | **rendered live** - ACF and Meta Box fields of every common type read back through `@VAR` / `@LOOP` off the delivered page, loops included |
@@ -700,6 +700,12 @@ post — `build_all.py` resets first for that reason.
700
700
  rows and `theme_zip_compare.php` reports a tree that does not match - delete
701
701
  nodes with a missing parent REPEATEDLY until none are left, because each pass
702
702
  orphans the next level down.
703
+ - **A width copied out of Elementor needs `maxWidth: "100%"` beside it.**
704
+ Elementor caps every widget with `max-width:100%`, so a px width there is a
705
+ desktop intent its own CSS already constrains; carried across bare it keeps the
706
+ full 600px and the page scrolls sideways on a phone. `verify_rwd` cannot see it
707
+ - the declaration did reach the stylesheet - and nineteen converted pages were
708
+ green on every per-page check while all nineteen overflowed at 390px.
703
709
  - **Only three interaction types can be an `interactionShorthand`**: `pageLoad`,
704
710
  `exitIntent`, `scrollDepth` (`InteractionTypesManager::getShorthandInteractionTypes`).
705
711
  Any other is dropped in silence - the modal renders with its aria and its
@@ -713,9 +719,13 @@ post — `build_all.py` resets first for that reason.
713
719
  itself at the end it cannot pass unless the slider is a carousel, so a test
714
720
  that clicks `next` after autoplay has parked the slider finds nothing there.
715
721
  - **An interaction that targets another element stores that element's NODE id**,
716
- not its attrID, so both have to be written in one pass (`nodeID` on a spec
717
- node pins it). The id must be unique in the THEME - `wp_mosaic_nodes`' primary
718
- key is `(ID, themeID)` - so mint it per run rather than hard-coding one.
722
+ not its attrID, so both have to be written in one pass (`nodeID` on a spec node
723
+ pins it). The pin means "these two halves must agree", never "this uuid must be
724
+ the one in the database" - `wp_mosaic_nodes`' key is `(ID, themeID)`, so a fixed
725
+ id makes the spec single-use and the SECOND build dies on a duplicate key with
726
+ the page half written. `build_page.refresh_node_ids()` now rewrites every pin and
727
+ every reference to it at build time, so a spec with pins rebuilds as many times
728
+ as you like.
719
729
  - **Since 1.0.9 the three per-breakpoint stylesheets are one.**
720
730
  `mosaic-theme-document-styles-inline-css` carries the base rules and the
721
731
  `@media` blocks together; the old `mosaic-theme-block-editor-styles_<bp>-inline-css`
@@ -789,7 +799,7 @@ post — `build_all.py` resets first for that reason.
789
799
  | `theme_zip_compare.php` | hold an imported copy against its source, tree for tree - every scoped table, the (parentType, type) shape, which ids survive, and orphans named rather than counted |
790
800
  | `theme_delete.php` | remove a theme completely through the plugin's own routine; refuses the live one |
791
801
  | `verify_slider.py` | the slider in a real browser: does it advance, do the arrows and bullets drive it, does the keyboard, does it stop when motion is not wanted |
792
- | `verify_navigation.py` | a multi-page site: is every menu item a real `<a href>`, and does every page answer 200 (a template that was never bound answers 406 with an empty body, which only a logged-out visitor sees) |
802
+ | `verify_navigation.py` | a multi-page site: is every menu item a real `<a href>`, does every page answer 200 (a template that was never bound answers 406 with an empty body, which only a logged-out visitor sees), and with `--viewports` does any page scroll SIDEWAYS - the one question no per-page checker asks |
793
803
  | `verify_dialog.py` | the 1.0.9 modal in a real browser: does it open, close, answer Esc, remember, cap - and does an OpenStreetMap carry the coordinates it was given |
794
804
  | `list_fields.php` | every `@VAR` / `@LOOP` name Mosaic registers for one post - custom fields, their derived `__label` / `__url` / `__id` properties, the row variables of each loop - with the value each resolves to (`wp eval-file`) |
795
805
  | `data_upgrade.py` | after a plugin update, run Mosaic's data migration over its own milestone route - the step wp-admin does from a screen - and set the config's version when the editor API is back |
@@ -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.0"
23
+ "version": "1.21.1"
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.0"
23
+ "version": "1.21.1"
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.0"
23
+ "version": "1.21.1"
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.0"
23
+ "version": "1.21.1"
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.0"
23
+ "version": "1.21.1"
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,
@@ -1,28 +1,47 @@
1
1
  check,result,detail
2
- FRONT_PAGE,PASS,"HTTP 200, 112547 bytes"
2
+ FRONT_PAGE,PASS,"HTTP 200, 112365 bytes"
3
3
  MENU_IS_LINKS,PASS,6 real <a href> in #mw-nav
4
- NAV 首頁,PASS,"// -> HTTP 200, 112546 bytes"
5
- NAV 服務,PASS,"/services/ -> HTTP 200, 156300 bytes"
6
- NAV 作品,PASS,"/works/ -> HTTP 200, 108284 bytes"
7
- NAV 報價,PASS,"/plan/ -> HTTP 200, 181490 bytes"
8
- NAV 關於,PASS,"/about/ -> HTTP 200, 66218 bytes"
9
- NAV 聯絡,PASS,"/contact/ -> HTTP 200, 49644 bytes"
10
- PAGE home,PASS,"HTTP 200, 112547 bytes"
11
- PAGE services,PASS,"HTTP 200, 156300 bytes"
12
- PAGE mowp,PASS,"HTTP 200, 103101 bytes"
13
- PAGE weebly-migration,PASS,"HTTP 200, 96174 bytes"
14
- PAGE works,PASS,"HTTP 200, 108284 bytes"
15
- PAGE plan,PASS,"HTTP 200, 181490 bytes"
16
- PAGE about,PASS,"HTTP 200, 66218 bytes"
17
- PAGE teams,PASS,"HTTP 200, 67671 bytes"
18
- PAGE open-source,PASS,"HTTP 200, 115360 bytes"
19
- PAGE order-notify-guide,PASS,"HTTP 200, 39646 bytes"
20
- PAGE contact,PASS,"HTTP 200, 49644 bytes"
21
- PAGE case-drpen,PASS,"HTTP 200, 89043 bytes"
22
- PAGE case-lyycamp,PASS,"HTTP 200, 87978 bytes"
23
- PAGE case-em925,PASS,"HTTP 200, 86621 bytes"
24
- PAGE case-sweetluck,PASS,"HTTP 200, 87483 bytes"
25
- PAGE case-amclinic,PASS,"HTTP 200, 86760 bytes"
26
- PAGE case-ruinart,PASS,"HTTP 200, 87000 bytes"
27
- PAGE case-medical-erp,PASS,"HTTP 200, 88166 bytes"
28
- PAGE case-igoods,PASS,"HTTP 200, 86214 bytes"
4
+ NAV 首頁,PASS,"// -> HTTP 200, 112360 bytes"
5
+ NAV 服務,PASS,"/services/ -> HTTP 200, 156950 bytes"
6
+ NAV 作品,PASS,"/works/ -> HTTP 200, 109070 bytes"
7
+ NAV 報價,PASS,"/plan/ -> HTTP 200, 181775 bytes"
8
+ NAV 關於,PASS,"/about/ -> HTTP 200, 66293 bytes"
9
+ NAV 聯絡,PASS,"/contact/ -> HTTP 200, 49689 bytes"
10
+ PAGE home,PASS,"HTTP 200, 112741 bytes"
11
+ PAGE services,PASS,"HTTP 200, 156570 bytes"
12
+ PAGE mowp,PASS,"HTTP 200, 103205 bytes"
13
+ PAGE weebly-migration,PASS,"HTTP 200, 96324 bytes"
14
+ PAGE works,PASS,"HTTP 200, 109452 bytes"
15
+ PAGE plan,PASS,"HTTP 200, 182156 bytes"
16
+ PAGE about,PASS,"HTTP 200, 65912 bytes"
17
+ PAGE teams,PASS,"HTTP 200, 68157 bytes"
18
+ PAGE open-source,PASS,"HTTP 200, 115144 bytes"
19
+ PAGE order-notify-guide,PASS,"HTTP 200, 40027 bytes"
20
+ PAGE contact,PASS,"HTTP 200, 49689 bytes"
21
+ PAGE case-drpen,PASS,"HTTP 200, 89163 bytes"
22
+ PAGE case-lyycamp,PASS,"HTTP 200, 88098 bytes"
23
+ PAGE case-em925,PASS,"HTTP 200, 86740 bytes"
24
+ PAGE case-sweetluck,PASS,"HTTP 200, 87603 bytes"
25
+ PAGE case-amclinic,PASS,"HTTP 200, 86880 bytes"
26
+ PAGE case-ruinart,PASS,"HTTP 200, 87120 bytes"
27
+ PAGE case-medical-erp,PASS,"HTTP 200, 88285 bytes"
28
+ PAGE case-igoods,PASS,"HTTP 200, 86334 bytes"
29
+ WIDTH home,PASS,"no sideways scroll at 390,768,1280"
30
+ WIDTH services,PASS,"no sideways scroll at 390,768,1280"
31
+ WIDTH mowp,PASS,"no sideways scroll at 390,768,1280"
32
+ WIDTH weebly-migration,PASS,"no sideways scroll at 390,768,1280"
33
+ WIDTH works,PASS,"no sideways scroll at 390,768,1280"
34
+ WIDTH plan,PASS,"no sideways scroll at 390,768,1280"
35
+ WIDTH about,PASS,"no sideways scroll at 390,768,1280"
36
+ WIDTH teams,PASS,"no sideways scroll at 390,768,1280"
37
+ WIDTH open-source,PASS,"no sideways scroll at 390,768,1280"
38
+ WIDTH order-notify-guide,PASS,"no sideways scroll at 390,768,1280"
39
+ WIDTH contact,PASS,"no sideways scroll at 390,768,1280"
40
+ WIDTH case-drpen,PASS,"no sideways scroll at 390,768,1280"
41
+ WIDTH case-lyycamp,PASS,"no sideways scroll at 390,768,1280"
42
+ WIDTH case-em925,PASS,"no sideways scroll at 390,768,1280"
43
+ WIDTH case-sweetluck,PASS,"no sideways scroll at 390,768,1280"
44
+ WIDTH case-amclinic,PASS,"no sideways scroll at 390,768,1280"
45
+ WIDTH case-ruinart,PASS,"no sideways scroll at 390,768,1280"
46
+ WIDTH case-medical-erp,PASS,"no sideways scroll at 390,768,1280"
47
+ WIDTH case-igoods,PASS,"no sideways scroll at 390,768,1280"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mosaic-headless",
3
- "version": "1.21.0",
3
+ "version": "1.21.1",
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",
@@ -68,8 +68,11 @@ which the page resolves to `{"targetSelectorData": [{"type": "all", "selector":
68
68
  `nodeID` on any spec node for exactly this.
69
69
  - **That id must be unique in the THEME, not the document.** `wp_mosaic_nodes`'
70
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.
71
+ earlier build left behind — the commit answers HTTP 500 on a duplicate key, with
72
+ the page already half written. `build_page.refresh_node_ids()` rewrites every
73
+ pinned `nodeID` and every reference to it as the tree is built, so the pin keeps
74
+ doing its real job (two halves of one spec agreeing) without claiming the uuid
75
+ itself, and a spec carrying pins can be rebuilt any number of times.
73
76
 
74
77
  `type` accepts `element`, `variant` or `universalClass` — the last two aim an action
75
78
  at every element of a class.
@@ -71,3 +71,22 @@ revision is rejected, and the rejection arrives as HTTP 200. See
71
71
  - An Elementor site, page-level construction → `elementor-headless`.
72
72
  - A Mosaic site → here. The write path and all 74 free node types are measured; the
73
73
  48 Pro-only types are extracted from source but not render-verified.
74
+
75
+ ## One thing the converter has to add, not copy
76
+
77
+ Elementor's own stylesheet caps every widget with `max-width: 100%`. So a width
78
+ set in the editor - `_element_custom_width: {size: 600, unit: px}`, with no mobile
79
+ value beside it - is a DESKTOP INTENT: Elementor renders it as 346px inside a 346px
80
+ column on a phone, and the author never sees a problem.
81
+
82
+ Copy that width across on its own and the element keeps all 600px. Nineteen
83
+ converted pages did exactly that: at 390px the document was 662px wide and every
84
+ page scrolled sideways, while the Elementor original at the same viewport did not.
85
+ Nothing in the per-page checks caught it, and that is the instructive part -
86
+ `verify_rwd` asks whether a declaration reached the stylesheet, and a 640px width
87
+ on a phone reaches it perfectly.
88
+
89
+ `from_elementor.py` now emits `maxWidth: "100%"` beside any px `width` it carries
90
+ over, after the element handlers have had their say so a width a handler supplies
91
+ is capped too. `verify_navigation.py --viewports 390,768,1280` opens every page of
92
+ a site and fails the one that scrolls sideways, naming the widest element in it.
@@ -100,7 +100,7 @@ const counts = {
100
100
  "data/custom-fields-verification.csv": 62,
101
101
  "data/dialog-verification.csv": 19,
102
102
  "data/slider-verification.csv": 19,
103
- "data/navigation-verification.csv": 27,
103
+ "data/navigation-verification.csv": 46,
104
104
  "data/page-modal-verification.csv": 19,
105
105
  };
106
106
  for (const [file, expected] of Object.entries(counts)) {
@@ -229,6 +229,44 @@ def to_style(spec_style):
229
229
  COMPONENT_USES = []
230
230
 
231
231
 
232
+
233
+ def refresh_node_ids(tree):
234
+ """Give every pinned `nodeID` in a tree a fresh uuid, and carry the rename
235
+ through every reference to it.
236
+
237
+ A pin exists so that two nodes written in the SAME pass can agree on an id -
238
+ a `modalOpen` action names the modal's node id, and neither exists yet. It is
239
+ not a claim that this exact uuid must be the one in the database, and treating
240
+ it as one makes the spec single-use: `wp_mosaic_nodes`' primary key is
241
+ (ID, themeID), so the second build of the same spec into the same theme dies
242
+ on a duplicate key, with the page already half written.
243
+
244
+ Rewriting at build time keeps the agreement (both halves move together) and
245
+ drops the false claim. Done on the serialized tree because a reference can sit
246
+ anywhere - `settings.target.uuid` today, somewhere else tomorrow - and a
247
+ structural walk would have to know every one of them.
248
+ """
249
+ ids = []
250
+
251
+ def collect(node):
252
+ if isinstance(node, dict):
253
+ if isinstance(node.get("nodeID"), str):
254
+ ids.append(node["nodeID"])
255
+ for v in node.values():
256
+ collect(v)
257
+ elif isinstance(node, list):
258
+ for v in node:
259
+ collect(v)
260
+
261
+ collect(tree)
262
+ if not ids:
263
+ return tree
264
+ blob = json.dumps(tree, ensure_ascii=False)
265
+ for old in dict.fromkeys(ids):
266
+ blob = blob.replace(old, str(uuid.uuid4()))
267
+ return json.loads(blob)
268
+
269
+
232
270
  def flatten(node, parent_id, master_id, surface, force, ordering="a0", parent_type=None, out=None):
233
271
  """Depth-first walk producing revision records, checking placement as it goes."""
234
272
  out = out if out is not None else []
@@ -417,7 +455,8 @@ def build(client, cfg, spec, force):
417
455
  VAR_IDS.clear()
418
456
  extra = theme_records(spec, doc, surface)
419
457
 
420
- records = flatten(spec["tree"], body["ID"], master_id, surface, force, parent_type="body")
458
+ records = flatten(refresh_node_ids(spec["tree"]), body["ID"], master_id, surface,
459
+ force, parent_type="body")
421
460
  payload = dict(extra)
422
461
  payload[key] = [{"newRevisionRecord": r, "originalRevisionRecord": None} for r in records]
423
462
  resp = client.commit("masterDocumentInstance/%s" % master_id, envelopes(doc), payload)
@@ -37,7 +37,7 @@ import urllib.parse
37
37
  import uuid
38
38
 
39
39
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
40
- from build_page import ( # noqa: E402
40
+ from build_page import (refresh_node_ids, # noqa: E402
41
41
  COMPONENT_USES,
42
42
  VAR_IDS,
43
43
  Surface,
@@ -323,7 +323,8 @@ def build_page_document(client, cfg, master_id, template_id, tree, surface):
323
323
  sys.exit("template %s has no template-internal root" % template_id[:8])
324
324
 
325
325
  COMPONENT_USES.clear()
326
- records = flatten(tree, root["ID"], template_id, surface, False, parent_type="template-internal")
326
+ records = flatten(refresh_node_ids(tree), root["ID"], template_id, surface,
327
+ False, parent_type="template-internal")
327
328
  for r in records:
328
329
  r["documentType"] = "template" # these rows belong to the template,
329
330
  r["documentID"] = template_id # not to the master that frames them
@@ -179,6 +179,17 @@ def style_for(settings, bp, extra_map=None):
179
179
  if extra_map:
180
180
  st.update(extra_map)
181
181
 
182
+ # A px width out of Elementor is a DESKTOP INTENT, not a size the element
183
+ # holds at every width - Elementor's own stylesheet caps every widget with
184
+ # `max-width:100%`, so `_element_custom_width: 600px` renders 346px inside a
185
+ # 346px column on a phone. Carrying the width across without the cap is the
186
+ # one difference that made converted pages scroll sideways at 390px while the
187
+ # Elementor original did not: the element kept its 600px and pushed the
188
+ # document to 662px. Measured on both, same page, same viewport.
189
+ if st.get("width", "").endswith("px") and "maxWidth" not in st:
190
+ st["maxWidth"] = "100%"
191
+
192
+
182
193
  # Borders: Mosaic's per-side longhands are grouped and inert on their own
183
194
  # (SKILL.md - 20 properties, zero exceptions), so this goes through
184
195
  # customDeclarations, which is the documented way out.
@@ -17,6 +17,13 @@ Then every page is fetched, because a one-page site converted into a real one ha
17
17
  a specific failure: the template binding is per post, and a page whose template was
18
18
  never bound answers 406 with an empty body to anyone not logged in - which a
19
19
  logged-in author never sees.
20
+
21
+ With `--viewports` each page is also opened in a browser at those widths and asked
22
+ whether the document scrolls SIDEWAYS, with the widest offender named. `verify_rwd`
23
+ cannot answer that - it checks that a declaration reached the stylesheet, which a
24
+ 640px width on a phone does perfectly - and `verify_browser` can, but only for the
25
+ pages of the one spec it is pointed at. A site is the unit here: nineteen converted
26
+ pages all overflowed at 390px and every per-page tool was green.
20
27
  """
21
28
  from __future__ import annotations
22
29
 
@@ -54,6 +61,29 @@ def anchors(html, container_id):
54
61
  m.group(1), re.S)]
55
62
 
56
63
 
64
+ # `scrollWidth > clientWidth` says the page scrolls sideways; it does not say what
65
+ # did it, and a report without the culprit sends someone hunting. Position:fixed is
66
+ # excluded - a fixed decoration outside the viewport is not what widens a document.
67
+ OVERFLOW = """() => {
68
+ const de = document.documentElement;
69
+ const over = de.scrollWidth - de.clientWidth;
70
+ let widest = null, edge = de.clientWidth + 1;
71
+ if (over > 1) {
72
+ for (const el of document.querySelectorAll('body *')) {
73
+ const r = el.getBoundingClientRect();
74
+ if (r.width === 0 || r.height === 0) continue;
75
+ if (getComputedStyle(el).position === 'fixed') continue;
76
+ if (r.right > edge) {
77
+ edge = r.right;
78
+ widest = el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') +
79
+ ' to ' + Math.round(r.right) + 'px';
80
+ }
81
+ }
82
+ }
83
+ return {over, doc: de.scrollWidth, view: de.clientWidth, widest};
84
+ }"""
85
+
86
+
57
87
  def main():
58
88
  ap = argparse.ArgumentParser()
59
89
  ap.add_argument("--site", required=True, help="the site spec that was built")
@@ -61,6 +91,9 @@ def main():
61
91
  ap.add_argument("--nav", default="mw-nav", help="attrID of the nav container")
62
92
  ap.add_argument("--home-slug", default="home",
63
93
  help="the slug bound to the front page, reachable at /")
94
+ ap.add_argument("--viewports",
95
+ help="comma-separated widths to check for sideways scroll, "
96
+ "e.g. 390,768,1280 (needs playwright)")
64
97
  ap.add_argument("--csv")
65
98
  a = ap.parse_args()
66
99
 
@@ -100,6 +133,31 @@ def main():
100
133
  ok = st == 200 and len(body) > 2000
101
134
  check("PAGE %s" % slug[:21], ok, "HTTP %d, %d bytes" % (st, len(body)))
102
135
 
136
+ # ── does any page scroll sideways ───────────────────────────────────────
137
+ if a.viewports:
138
+ widths = [int(w) for w in a.viewports.split(",") if w.strip()]
139
+ from playwright.sync_api import sync_playwright
140
+ with sync_playwright() as pw:
141
+ br = pw.chromium.launch()
142
+ for page in spec["pages"]:
143
+ slug = page["slug"]
144
+ url = base + ("/" if slug == a.home_slug else "/%s/" % slug)
145
+ bad = []
146
+ for w in widths:
147
+ pg = br.new_page(viewport={"width": w, "height": 880})
148
+ pg.goto("%s?_v=%d" % (url, int(time.time() * 1000)),
149
+ wait_until="load")
150
+ pg.wait_for_timeout(1200)
151
+ r = pg.evaluate(OVERFLOW)
152
+ if r["over"] > 1:
153
+ bad.append("%dpx: doc %d, widest %s"
154
+ % (w, r["doc"], r["widest"] or "?"))
155
+ pg.close()
156
+ check("WIDTH %s" % slug[:20], not bad,
157
+ ("no sideways scroll at %s" % a.viewports) if not bad
158
+ else "; ".join(bad))
159
+ br.close()
160
+
103
161
  print("\n%d of %d checks passed" % (len(rows) - len(fails), len(rows)))
104
162
  if a.csv:
105
163
  with open(a.csv, "w", newline="", encoding="utf-8") as fh: