mosaic-headless 1.21.0 → 1.21.2

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.2"
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,18 @@ 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
+ - **`width` / `height` and their min/max take the keyword `stretch` since 1.0.9**,
704
+ and Mosaic emits `-webkit-fill-available` before it so an older engine still gets
705
+ something. They are still token-referencable - `CSSSizePropertyFactory` extends
706
+ the collection-variable one, which is why `data/style-properties.csv` resolves a
707
+ factory's ancestry rather than testing its name. 39 properties take a token, not
708
+ the 34 counted before that fix.
709
+ - **A width copied out of Elementor needs `maxWidth: "100%"` beside it.**
710
+ Elementor caps every widget with `max-width:100%`, so a px width there is a
711
+ desktop intent its own CSS already constrains; carried across bare it keeps the
712
+ full 600px and the page scrolls sideways on a phone. `verify_rwd` cannot see it
713
+ - the declaration did reach the stylesheet - and nineteen converted pages were
714
+ green on every per-page check while all nineteen overflowed at 390px.
703
715
  - **Only three interaction types can be an `interactionShorthand`**: `pageLoad`,
704
716
  `exitIntent`, `scrollDepth` (`InteractionTypesManager::getShorthandInteractionTypes`).
705
717
  Any other is dropped in silence - the modal renders with its aria and its
@@ -713,9 +725,13 @@ post — `build_all.py` resets first for that reason.
713
725
  itself at the end it cannot pass unless the slider is a carousel, so a test
714
726
  that clicks `next` after autoplay has parked the slider finds nothing there.
715
727
  - **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.
728
+ not its attrID, so both have to be written in one pass (`nodeID` on a spec node
729
+ pins it). The pin means "these two halves must agree", never "this uuid must be
730
+ the one in the database" - `wp_mosaic_nodes`' key is `(ID, themeID)`, so a fixed
731
+ id makes the spec single-use and the SECOND build dies on a duplicate key with
732
+ the page half written. `build_page.refresh_node_ids()` now rewrites every pin and
733
+ every reference to it at build time, so a spec with pins rebuilds as many times
734
+ as you like.
719
735
  - **Since 1.0.9 the three per-breakpoint stylesheets are one.**
720
736
  `mosaic-theme-document-styles-inline-css` carries the base rules and the
721
737
  `@media` blocks together; the old `mosaic-theme-block-editor-styles_<bp>-inline-css`
@@ -789,7 +805,7 @@ post — `build_all.py` resets first for that reason.
789
805
  | `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
806
  | `theme_delete.php` | remove a theme completely through the plugin's own routine; refuses the live one |
791
807
  | `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) |
808
+ | `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
809
  | `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
810
  | `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
811
  | `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.2"
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.2"
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.2"
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.2"
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.2"
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"
@@ -38,12 +38,12 @@ wordSpacing,,CSSCollectionVariablePropertyFactory,,,yes
38
38
  whiteSpace,,CSSPropertyFactory,,normal|pre-wrap|nowrap|pre,
39
39
  textTransform,,CSSPropertyFactory,,none|lowercase|uppercase|capitalize,
40
40
  textShadow,,CSSTextShadowStylePropertyFactory,,,
41
- width,,CSSSizePropertyFactory,,,
42
- minWidth,,CSSSizePropertyFactory,,,
43
- maxWidth,,CSSSizePropertyFactory,,,
44
- height,,CSSSizePropertyFactory,,,
45
- minHeight,,CSSSizePropertyFactory,,,
46
- maxHeight,,CSSSizePropertyFactory,,,
41
+ width,,CSSSizePropertyFactory,,,yes
42
+ minWidth,,CSSSizePropertyFactory,,,yes
43
+ maxWidth,,CSSSizePropertyFactory,,,yes
44
+ height,,CSSSizePropertyFactory,,,yes
45
+ minHeight,,CSSSizePropertyFactory,,,yes
46
+ maxHeight,,CSSSizePropertyFactory,,,yes
47
47
  position,,CSSPropertyFactory,,static|relative|absolute|fixed|sticky,
48
48
  zIndex,,CSSPropertyFactory,,,
49
49
  top,,CSSCollectionVariablePropertyFactory,,,yes
@@ -61,19 +61,19 @@ paddingLeft,,CSSCollectionVariablePropertyFactory,,,yes
61
61
  borderRadius,,CSSBorderRadiusPropertyFactory,,,
62
62
  borderTopWidth,borderStyle,CSSGrouppedPropertyFactory,CSSCollectionVariableProperty,,yes
63
63
  borderTopStyle,borderStyle,CSSGrouppedPropertyFactory,,,
64
- borderTopColor,borderStyle,CSSGrouppedPropertyFactory,CSSColorProperty,,
64
+ borderTopColor,borderStyle,CSSGrouppedPropertyFactory,CSSColorProperty,,yes
65
65
  borderRightWidth,borderStyle,CSSGrouppedPropertyFactory,CSSCollectionVariableProperty,,yes
66
66
  borderRightStyle,borderStyle,CSSGrouppedPropertyFactory,,,
67
- borderRightColor,borderStyle,CSSGrouppedPropertyFactory,CSSColorProperty,,
67
+ borderRightColor,borderStyle,CSSGrouppedPropertyFactory,CSSColorProperty,,yes
68
68
  borderBottomWidth,borderStyle,CSSGrouppedPropertyFactory,CSSCollectionVariableProperty,,yes
69
69
  borderBottomStyle,borderStyle,CSSGrouppedPropertyFactory,,,
70
- borderBottomColor,borderStyle,CSSGrouppedPropertyFactory,CSSColorProperty,,
70
+ borderBottomColor,borderStyle,CSSGrouppedPropertyFactory,CSSColorProperty,,yes
71
71
  borderLeftWidth,borderStyle,CSSGrouppedPropertyFactory,CSSCollectionVariableProperty,,yes
72
72
  borderLeftStyle,borderStyle,CSSGrouppedPropertyFactory,,,
73
- borderLeftColor,borderStyle,CSSGrouppedPropertyFactory,CSSColorProperty,,
73
+ borderLeftColor,borderStyle,CSSGrouppedPropertyFactory,CSSColorProperty,,yes
74
74
  outlineWidth,outlineStyle,CSSGrouppedPropertyFactory,CSSCollectionVariableProperty,,yes
75
75
  outlineStyle,outlineStyle,CSSGrouppedPropertyFactory,,,
76
- outlineColor,outlineStyle,CSSGrouppedPropertyFactory,CSSColorProperty,,
76
+ outlineColor,outlineStyle,CSSGrouppedPropertyFactory,CSSColorProperty,,yes
77
77
  outlineOffset,outlineStyle,CSSGrouppedPropertyFactory,CSSCollectionVariableProperty,,yes
78
78
  boxShadow,,CSSBoxShadowStylePropertyFactory,,,
79
79
  opacity,,CSSPropertyFactory,,,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mosaic-headless",
3
- "version": "1.21.0",
3
+ "version": "1.21.2",
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.
@@ -195,6 +198,33 @@ anywhere. That is the skill's existing rule (`ValidatorDynamicCode` = bare strin
195
198
  `ValidatorDynamicCodeObject` = `{"v": …}`) applying to a new element, and it is also
196
199
  why those three can hold `@VAR('post/meta_lat')` and a custom field can drive the map.
197
200
 
201
+ ## The rest of 1.0.9, briefly
202
+
203
+ Not everything in the release is a node you place. Four things that change how the
204
+ plugin behaves rather than what you can build:
205
+
206
+ - **`stretch` on the size properties.** `width`, `height` and their min/max moved to
207
+ `CSSSizePropertyFactory`, which accepts the keyword and emits
208
+ `width:-webkit-fill-available` **then** `width:stretch` — the modern value last so
209
+ a current engine lands on it and an older one keeps the alias. Measured on all
210
+ three of width / height / max-width; the author's casing is preserved as typed.
211
+ - **`ElementClassPruner`.** The generated `_<token>` class is now a placeholder while
212
+ the tree renders, and one pass over the finished HTML keeps it only where a
213
+ selector actually claimed it. The marker is random per render, so the pass can
214
+ never touch a class that arrived from post content or a shortcode. This is the
215
+ mechanism behind "an element whose styles compile to nothing has no generated
216
+ class" — it is a deliberate feature, not an accident.
217
+ - **Author and term page instances.** `PageInstance/PageTypes/Author` and `Term` let
218
+ the editor put a specific author or taxonomy term in front of an archive template;
219
+ a term has no assign of its own, because a term archive is only ever rendered by
220
+ its path.
221
+ - **`POST /template/createAutoTemplate`** (`resourceQuery`, `masterID`) creates the
222
+ automatic template a post type is missing — a template on the post type's PATH,
223
+ with nothing recorded against the resource that prompted it. Deliberately not the
224
+ assign endpoint: a post type whose resources cannot carry an assign still needs a
225
+ way to be designed, and conflating the two would make this look like an assign.
226
+ 115 routes now, up from 114.
227
+
198
228
  ## What else moved in the delivered page
199
229
 
200
230
  **The three per-breakpoint stylesheets became one.** Through 1.0.8 a page carried
@@ -109,12 +109,13 @@ The `factory` column in `data/style-properties.csv` tells you the value shape:
109
109
  | factory | write |
110
110
  |---|---|
111
111
  | `CSSPropertyFactory` (29) | a plain CSS value string — `"flex"`, `"center"`, `"700"` |
112
- | `CSSCollectionVariablePropertyFactory` (26) | a length string, or a reference to a collection variable |
112
+ | `CSSCollectionVariablePropertyFactory` (20) | a length string, or a reference to a collection variable |
113
+ | `CSSSizePropertyFactory` (6) | `width`, `height` and their min/max. A collection-variable length like the row above - it is a subclass - plus the keyword **`stretch`**, which Mosaic emits as `-webkit-fill-available` FIRST and `stretch` second, so a current engine takes the standard value and an older one the alias. New in 1.0.9; measured. |
113
114
  | `CSSColorPropertyFactory` (2) | `color`, `backgroundColor` — a colour string or variable reference |
114
115
  | `CSSGrouppedPropertyFactory` (20) | one leg of `borderStyle`, `outlineStyle` or `gridChildPosition`; the data is keyed under the group |
115
116
  | the other 21 | purpose-built shapes — shadow, transform, gradient, filter, mask. Read the factory named in the CSV. |
116
117
 
117
- Plain strings were accepted verbatim for the properties probed above. The 34
118
+ Plain strings were accepted verbatim for the properties probed above. The 39
118
119
  token-referencable properties are the interface to the collection/variable layer,
119
120
  which is how a design system is meant to be expressed rather than hard-coding values
120
121
  on every node.
@@ -127,7 +128,11 @@ lowest layer and it opts that node out of both reuse mechanisms:
127
128
  1. **Variants** (`data/variants.csv`, 152 built in - Mosaic's element classes) carry
128
129
  the shared look; a node points at one through `style.variant`.
129
130
  2. **Collections → modes/skins → variables** are the token layer, the thing the
130
- 34 token-referencable properties reference.
131
+ 39 token-referencable properties reference. (34 until this was last counted:
132
+ `data/style-properties.csv` resolves a factory's ANCESTRY now rather than
133
+ testing its name, which 1.0.9 made necessary - `CSSSizePropertyFactory` extends
134
+ the collection-variable one - and which also recovered the five grouped colour
135
+ legs whose value class was always `CSSColorProperty`.)
131
136
 
132
137
  Per-node `style` is for the exception, not the rule. Build a design by putting values
133
138
  in the class and token layers and letting nodes inherit; reach for per-node style when
@@ -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
@@ -110,6 +110,44 @@ def style_enums(plugin_root):
110
110
  return out
111
111
 
112
112
 
113
+ def factory_parents(plugin_root):
114
+ """class -> its parent, for every property factory under Builder/Style.
115
+
116
+ Read because a factory's NAME stopped being enough in 1.0.9:
117
+ `CSSSizePropertyFactory extends CSSCollectionVariablePropertyFactory` and drives
118
+ width / height and their min and max, which still take a collection variable -
119
+ but a name test for "CollectionVariable" misses it and the table then says six
120
+ length properties cannot reference a design token, which is false.
121
+ """
122
+ parents = {}
123
+ base = os.path.join(plugin_root, "Mosaic", "Builder", "Style")
124
+ for root, _dirs, files in os.walk(base):
125
+ for name in files:
126
+ if not name.endswith(".php"):
127
+ continue
128
+ with open(os.path.join(root, name), encoding="utf-8", errors="replace") as fh:
129
+ src = fh.read()
130
+ # `extends Foo` and `extends \Name\Space\Foo` both; only the last
131
+ # segment is kept, which is what the factory column shows.
132
+ bs = chr(92)
133
+ for m in re.finditer("class" + r"\s+(\w+)\s+extends\s+([" + bs * 2
134
+ + r"\w]+)", src):
135
+ parents[m.group(1)] = m.group(2).split(bs)[-1]
136
+ return parents
137
+
138
+
139
+ def is_tokenable(factory, value_class, parents):
140
+ """Does this factory, or anything it inherits from, accept a collection variable?"""
141
+ for name in (factory, value_class):
142
+ seen = set()
143
+ while name and name not in seen:
144
+ if "CollectionVariable" in name or "Color" in name:
145
+ return True
146
+ seen.add(name)
147
+ name = parents.get(name)
148
+ return False
149
+
150
+
113
151
  def extract(plugin_root, out_dir):
114
152
  enums = style_enums(plugin_root)
115
153
  path = os.path.join(plugin_root, "Mosaic", "Builder", "Style", "SupportedStyleProperties.php")
@@ -118,6 +156,7 @@ def extract(plugin_root, out_dir):
118
156
  with open(path, encoding="utf-8", errors="replace") as fh:
119
157
  src = fh.read()
120
158
 
159
+ parents = factory_parents(plugin_root)
121
160
  rows = []
122
161
  for factory, first, member, value_class in RE_ADD.findall(src):
123
162
  grouped = factory == "CSSGrouppedPropertyFactory"
@@ -128,10 +167,9 @@ def extract(plugin_root, out_dir):
128
167
  "factory": factory,
129
168
  "value_class": value_class.split("\\")[-1] if value_class else "",
130
169
  "accepted_values": "|".join(enums.get(member if grouped else first, [])),
131
- "tokenable": "yes" if (
132
- "CollectionVariable" in factory or "Color" in factory
133
- or "CollectionVariable" in (value_class or "")
134
- ) else "",
170
+ "tokenable": "yes" if is_tokenable(
171
+ factory, value_class.split("\\")[-1] if value_class else "",
172
+ parents) else "",
135
173
  }
136
174
  )
137
175
 
@@ -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: