mosaic-headless 1.20.0 → 1.21.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.
@@ -2,25 +2,25 @@ check,result,detail
2
2
  PLAYS,PASS,43 of 43 intro elements changed state
3
3
  COUNTS,PASS,--mk-n reached 100
4
4
  ENDS,PASS,veil is out of hit-testing from 8900ms
5
- NO_TRAP,PASS,"0 content elements still at opacity 0 in the viewport (60 below the fold and 1 on a scroll timeline awaiting it, 1 mid-blink)"
5
+ NO_TRAP,PASS,"0 content elements still at opacity 0 in the viewport (1 below the fold and 0 on a scroll timeline awaiting it, 1 mid-blink)"
6
6
  CLEARS,PASS,a real click landed on 'mk-caret-row'
7
- AMBIENT,PASS,"9 elements still moving between 11000ms and 13000ms with no input: mk-board-city-s, mk-board-utc-s, mk-clock-s, mk-flap-0-s, mk-flap-1-s, mk-flap-2-s"
7
+ AMBIENT,PASS,"7 elements still moving between 11000ms and 13000ms with no input: mk-board-city-s, mk-board-utc-s, mk-clock-s, mk-flap-0-s, mk-flap-1-s, mk-flap-2-s"
8
8
  DEGRADES,PASS,with reduced motion the veil is display:none throughout
9
9
  READABLE,PASS,0 elements invisible at 120ms with reduced motion
10
10
 
11
11
  t_ms,counter,veil_visibility,veil_clip,veil_covers_centre,hit_centre,invisible_content
12
12
  120,,,,False,html.no-js,0
13
13
  700,--mk-n=0;--mk-s=0;--mk-m=0,visible,none,True,mk-boot-num,7
14
- 1400,--mk-n=0;--mk-s=0;--mk-m=0,visible,none,True,mk-boot-num,7
15
- 2200,--mk-n=34;--mk-s=0;--mk-m=0,visible,none,True,mk-boot-num,3
16
- 3000,--mk-n=67;--mk-s=0;--mk-m=0,visible,none,True,mk-boot-num,3
17
- 3800,--mk-n=85;--mk-s=0;--mk-m=0,visible,none,True,mk-boot-num,3
18
- 4600,--mk-n=94;--mk-s=0;--mk-m=0,visible,none,True,mk-boot-num,3
14
+ 1400,--mk-n=5;--mk-s=0;--mk-m=0,visible,none,True,mk-boot-num,7
15
+ 2200,--mk-n=40;--mk-s=0;--mk-m=0,visible,none,True,mk-boot-num,3
16
+ 3000,--mk-n=71;--mk-s=0;--mk-m=0,visible,none,True,mk-boot-num,3
17
+ 3800,--mk-n=87;--mk-s=0;--mk-m=0,visible,none,True,mk-boot-num,3
18
+ 4600,--mk-n=95;--mk-s=0;--mk-m=0,visible,none,True,mk-boot-num,3
19
19
  5400,--mk-n=98;--mk-s=0;--mk-m=0,visible,none,True,mk-boot-num,3
20
20
  6200,--mk-n=100;--mk-s=0;--mk-m=0,visible,none,True,mk-boot-num,3
21
21
  7000,--mk-n=100;--mk-s=0;--mk-m=0,visible,none,True,mk-boot-num,3
22
- 7800,--mk-n=100;--mk-s=0;--mk-m=0,visible,none,True,mk-boot-num,3
23
- 8300,--mk-n=100;--mk-s=0;--mk-m=0,visible,none,False,mk-caret-row,2
22
+ 7800,--mk-n=100;--mk-s=0;--mk-m=0,visible,none,True,p.m-text,2
23
+ 8300,--mk-n=100;--mk-s=0;--mk-m=0,visible,none,False,mk-caret-row,1
24
24
  8900,--mk-n=0;--mk-s=0;--mk-m=0,hidden,none,False,mk-caret-row,0
25
25
  11000,--mk-n=0;--mk-s=0;--mk-m=0,hidden,none,False,mk-caret-row,0
26
26
  13000,--mk-n=0;--mk-s=0;--mk-m=0,hidden,none,False,mk-caret-row,0
@@ -0,0 +1,28 @@
1
+ check,result,detail
2
+ FRONT_PAGE,PASS,"HTTP 200, 112547 bytes"
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"
@@ -0,0 +1,20 @@
1
+ page,result,trigger,run_rule,remember,memory_name,closedby,aria_modal,accessible_label
2
+ home,PASS,exitIntent,cap,session,mw-home,everything,yes,還沒找到要的東西?
3
+ services,PASS,scrollDepth,cooldown,forever,mw-services,everything,yes,想知道哪一項最適合?
4
+ mowp,PASS,exitIntent,cap,session,mw-mowp,everything,yes,需要同樣的東西嗎?
5
+ weebly-migration,PASS,exitIntent,cap,session,mw-weebly-migration,everything,yes,需要同樣的東西嗎?
6
+ works,PASS,scrollDepth,cap,session,mw-works,clickOutside,yes,想看完整的案例?
7
+ plan,PASS,pageLoad,firstRunWindow,session,mw-plan,esc,yes,報價只是起點
8
+ about,PASS,exitIntent,cap,forever,mw-about,nothing,yes,我們是誰,決定了做法
9
+ teams,PASS,exitIntent,cap,session,mw-teams,everything,yes,需要同樣的東西嗎?
10
+ open-source,PASS,exitIntent,cap,session,mw-open-source,everything,yes,需要同樣的東西嗎?
11
+ order-notify-guide,PASS,exitIntent,cap,session,mw-order-notify-guide,everything,yes,需要同樣的東西嗎?
12
+ contact,PASS,scrollDepth,cap,page,mw-contact,everything,yes,寫信太慢?
13
+ case-drpen,PASS,exitIntent,cap,session,mw-case-drpen,everything,yes,需要同樣的東西嗎?
14
+ case-lyycamp,PASS,exitIntent,cap,session,mw-case-lyycamp,everything,yes,需要同樣的東西嗎?
15
+ case-em925,PASS,exitIntent,cap,session,mw-case-em925,everything,yes,需要同樣的東西嗎?
16
+ case-sweetluck,PASS,exitIntent,cap,session,mw-case-sweetluck,everything,yes,需要同樣的東西嗎?
17
+ case-amclinic,PASS,exitIntent,cap,session,mw-case-amclinic,everything,yes,需要同樣的東西嗎?
18
+ case-ruinart,PASS,exitIntent,cap,session,mw-case-ruinart,everything,yes,需要同樣的東西嗎?
19
+ case-medical-erp,PASS,exitIntent,cap,session,mw-case-medical-erp,everything,yes,需要同樣的東西嗎?
20
+ case-igoods,PASS,exitIntent,cap,session,mw-case-igoods,everything,yes,需要同樣的東西嗎?
@@ -323,6 +323,8 @@ https://mosaic.moksaweb.com/moksa/,mk-contact-r-2,_m,grid-template-columns,84px
323
323
  https://mosaic.moksaweb.com/moksa/,mk-contact-r-2,_m,column-gap,12px,12px,verified
324
324
  https://mosaic.moksaweb.com/moksa/,mk-contact-r-3,_m,grid-template-columns,84px 1fr,84px 1fr,verified
325
325
  https://mosaic.moksaweb.com/moksa/,mk-contact-r-3,_m,column-gap,12px,12px,verified
326
+ https://mosaic.moksaweb.com/moksa/,mk-colophon,_m,padding-left,18px,18px,verified
327
+ https://mosaic.moksaweb.com/moksa/,mk-colophon,_m,padding-right,18px,18px,verified
326
328
  https://mosaic.moksaweb.com/moksa/,mk-header,_t,padding-left,28px,28px,verified
327
329
  https://mosaic.moksaweb.com/moksa/,mk-header,_t,padding-right,28px,28px,verified
328
330
  https://mosaic.moksaweb.com/moksa/,mk-header,_m,padding-left,18px,18px,verified
@@ -0,0 +1,20 @@
1
+ slider,check,result,detail
2
+ slide+autoplay,HOST,PASS,"<mosaic-slider> animation=slide carousel=None, 3 slides"
3
+ slide+autoplay,TRACK,PASS,<mosaic-slider-slides> aria-live=off
4
+ slide+autoplay,HAS_CURRENT,PASS,slide 0 is current at rest
5
+ slide+autoplay,BULLET_TEMPLATE,PASS,"3 bullets for 3 slides, all with id='sl-dot-0' - one authored bullet is repeated per slide, duplicate ids included"
6
+ slide+autoplay,BULLET_ARIA,PASS,"role=button tabindex=0 labels=ONE / 浮世繪, TWO / 版面, THREE / 色票 - taken from each slide's own title"
7
+ slide+autoplay,ADVANCES,PASS,reached 3 of 3 slides unaided
8
+ slide+autoplay,AUTOPLAY_DECLARED,PASS,data-autoplay=true delay=2200
9
+ slide+autoplay,BULLET_GOES,PASS,bullet 0 -> slide 0
10
+ slide+autoplay,ARROW_ENDS,PASS,"on slide 0 prev hides itself (hidden=True), next does not (hidden=False)"
11
+ slide+autoplay,ARROW_ARIA,PASS,role=button tabindex=0 label='下一張投影片'
12
+ slide+autoplay,NEXT,PASS,slide 0 -> 1
13
+ slide+autoplay,PREV,PASS,slide 1 -> 0
14
+ slide+autoplay,KEYBOARD,PASS,"focus bullet 2 taken, Enter -> slide 2"
15
+ slide+autoplay,REDUCED,PASS,under prefers-reduced-motion it holds still (slide 0 -> 0)
16
+ crossFade+carousel,HOST,PASS,"<mosaic-slider> animation=crossFade carousel=true, 4 slides"
17
+ crossFade+carousel,TRACK,PASS,<mosaic-slider-slides> aria-live=polite
18
+ crossFade+carousel,HAS_CURRENT,PASS,slide 0 is current at rest
19
+ crossFade+carousel,STAYS_PUT,PASS,autoplay off: still on slide 0 after 2.6s
20
+ crossFade+carousel,REDUCED,PASS,under prefers-reduced-motion it holds still (slide 0 -> 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mosaic-headless",
3
- "version": "1.20.0",
3
+ "version": "1.21.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",
@@ -27,10 +27,10 @@
27
27
  "author": "moksa (https://moksaweb.com)",
28
28
  "type": "module",
29
29
  "bin": {
30
- "mosaic-headless": "bin/install.mjs"
30
+ "mosaic-headless": "scripts/install.mjs"
31
31
  },
32
32
  "files": [
33
- "bin/",
33
+ "scripts/",
34
34
  "assets/",
35
35
  "data/*.csv",
36
36
  "references/",
@@ -50,10 +50,10 @@
50
50
  "node": ">=18"
51
51
  },
52
52
  "scripts": {
53
- "install-skill": "node bin/install.mjs",
54
- "check": "node bin/check-release.mjs",
53
+ "install-skill": "node scripts/install.mjs",
54
+ "check": "node scripts/check-release.mjs",
55
55
  "preversion": "npm run check",
56
- "version": "node bin/sync-version.mjs && git add SKILL.md assets/templates/platforms",
56
+ "version": "node scripts/sync-version.mjs && git add SKILL.md assets/templates/platforms .claude-plugin/plugin.json",
57
57
  "postversion": "git push && git push --tags",
58
58
  "prepublishOnly": "npm run check"
59
59
  }
@@ -133,7 +133,7 @@ then runs as a no-op that writes nothing, because the source skips an unnamed
133
133
  memory rather than inventing a key. The server reshapes the accepted one into
134
134
  `rememberOptions.timelines._.name`, which is what the payload shows.
135
135
 
136
- ## The interaction shorthand — modals only
136
+ ## The interaction shorthand — modals only, and three triggers only
137
137
 
138
138
  Every element's data can carry `interactionShorthand`, but `ModalElementMResourceData`
139
139
  is the only class that overrides `getInteractionShorthandActionTypes()` (it returns
@@ -156,6 +156,19 @@ the type, action from the element — and the page's `mosaicInteractions` gains
156
156
  with the cap attached. No trigger element, no action, no target: an exit-intent
157
157
  popup that fires once a session is one object.
158
158
 
159
+ **Only three interaction types can be a shorthand**, and the list is in the
160
+ source rather than inferable: `InteractionTypesManager::getShorthandInteractionTypes()`
161
+ returns `pageLoad`, `exitIntent`, `scrollDepth`. Those are exactly the three that do
162
+ not need the element to be visible or interactive first — which a modal never is,
163
+ since it is `display:none` until something opens it.
164
+
165
+ Anything else is **dropped in silence**. A modal given
166
+ `interactionShorthand: {"type": "elementScrollIntoView", …}` renders with its
167
+ `aria-modal`, its `aria-label` and its `data-mosaic-modal-closedby` all correct, the
168
+ page's `mosaicInteractions` carries nothing for it, and it never opens. Measured:
169
+ one page of the nineteen-page site was built that way and read back as the only
170
+ modal on the site with no trigger at all.
171
+
159
172
  `"type": "manual"` is the stored answer for "nothing opens this automatically" —
160
173
  spelled as a word rather than left absent, because absent means *inherit* on a
161
174
  component instance.
@@ -199,3 +212,64 @@ from the CSS, and a checker must not read "no token" as "the node did not render
199
212
  **`youtube`'s `privacy` property suppresses the element.** Set on its own it emits
200
213
  nothing at all, where 1.0.8 rendered the element and recorded NO_EFFECT. One row of
201
214
  `data/node-property-verification.csv`, the only `NO_OUTPUT` in the table.
215
+
216
+ ## The worked example's own modal
217
+
218
+ `sites/_moksa.py` carries one: a colophon card shown to a reader about to leave —
219
+ paper ground, hairline border, the document's own trim marks at two corners, a mono
220
+ eyebrow, one accent action and one quiet dismissal. It is built entirely from the
221
+ shorthand, so nothing on the page points at it:
222
+
223
+ ```python
224
+ COLOPHON_NODE_ID = str(uuid.uuid4()) # minted per generation, never fixed
225
+
226
+ {"type": "modal", "nodeID": COLOPHON_NODE_ID,
227
+ "data": {"accessibleLabel": "開始一個專案", "closedby": "everything",
228
+ "interactionShorthand": {
229
+ "type": "exitIntent",
230
+ "exitIntentOptions": {
231
+ "settings": {"awayFor": "500ms"},
232
+ "runs": {"rules": [{"uuid": …, "type": "cap",
233
+ "capOptions": {"max": "1", "remember": "session",
234
+ "name": "mk-colophon"}}]}}}},
235
+ "children": [modal-overlay, modal-window → the card]}
236
+ ```
237
+
238
+ 11 of 11 checks against the live page, and two things it taught on the way:
239
+
240
+ - **The card has to be a `modal-window`, not a styled `div`.** A plain div inside the
241
+ dialog renders and looks right, and `verify_dialog.py` STRUCTURE fails it — the
242
+ window is what Mosaic scrolls and focus-traps.
243
+ - **The design audit failed the first draft.** The heading carried
244
+ `letterSpacing: "-0.02em"`, inherited from the page's Latin display sizes, and
245
+ CJK_NEGATIVE_TRACKING caught it at −0.68px on a Chinese heading at two
246
+ breakpoints. A Han glyph sits on a full em body and is already as close as it is
247
+ meant to be; the line is `0.005em` now, matching the masthead.
248
+
249
+ ## A modal per page, each configured differently
250
+
251
+ A nineteen-page site was assembled from converted Elementor pages - one shared
252
+ shell, a menu of `menu-link` nodes, one WordPress page each - and every page given
253
+ its own modal. Six distinct configurations came out of it, and each one is a
254
+ different answer to "when, how often, and how do they get out":
255
+
256
+ | page | trigger | run rule | memory | closedby | what it is for |
257
+ |---|---|---|---|---|---|
258
+ | home | `exitIntent` 500ms | `cap` 1 | session | `everything` | the one offer, once a visit |
259
+ | services | `scrollDepth` 70% | `cooldown` 1 day | forever | `everything` | read most of it, then asked - and not again tomorrow |
260
+ | works | `scrollDepth` 40% | `cap` 2 | session | `clickOutside` | earlier, twice, dismissed by clicking away |
261
+ | plan | `pageLoad` | `firstRunWindow` 10 min | session | `esc` | immediate, but only in the first ten minutes of the visit |
262
+ | about | `exitIntent` 800ms | `cap` 1 | forever | `nothing` | once ever, and the only way out is the button inside |
263
+ | contact | `scrollDepth` 50% | `cap` 3 | page | `everything` | three times per page load, cheap and repeatable |
264
+
265
+ All six were read back out of each page's own `mosaicInteractions` payload
266
+ (`data/page-modal-verification.csv`), and three were then driven in a browser:
267
+ `plan` opened by itself on load and Esc closed it; `services` stayed shut at rest
268
+ and opened past 70% scroll; `about` opened on exit intent and Esc did **not** close
269
+ it, which is what `closedby: "nothing"` promises and what a cookie gate or an age
270
+ gate needs.
271
+
272
+ The pattern is worth stating on its own: because the trigger lives in the modal's
273
+ own `interactionShorthand`, a page's popup policy is one object in that page's data.
274
+ Nothing else on the page changes, no trigger element is placed, and two pages of the
275
+ same site can behave completely differently without sharing anything.
@@ -0,0 +1,79 @@
1
+ # The slider, measured
2
+
3
+ `sweep_node_types.py` records the whole slider family as `COMMIT_500` or
4
+ `BROKE_PAGE`. That is true of a bare `slider` under a plain `div` and says nothing
5
+ about the component — the same kind of row the accordion probe corrected. Nested
6
+ the way its factories require, every piece commits and renders.
7
+
8
+ `data/slider-verification.csv`: 19 checks across two sliders, all passing.
9
+
10
+ ## The shape
11
+
12
+ ```json
13
+ {"type": "slider",
14
+ "data": {"animation": "slide", "duration": "650", "easing": "ease",
15
+ "isAutoplay": "1", "autoplayDelay": "2200", "autoplayLimit": "0",
16
+ "isCarousel": "0", "defaultSlideIndex": "0", "ariaLabel": "…"},
17
+ "children": [
18
+ {"type": "slider-slides", "children": [
19
+ {"type": "slider-slide",
20
+ "data": {"image": "wp-attachment://image/5/full/…", "useAsBackground": "1",
21
+ "alt": "…", "title": "ONE", "positionX": "50%", "positionY": "50%"},
22
+ "children": [ … the caption, which rides inside the slide … ]},
23
+ …]},
24
+ {"type": "slider-navigation", "children": [
25
+ {"type": "slider-navigation-bullet"}]}, // ONE - see below
26
+ {"type": "slider-arrow-left"},
27
+ {"type": "slider-arrow-right"}]}
28
+ ```
29
+
30
+ `animation` is `slide` | `crossFade` | `fadeOut` | `fadeOver`. `autoplayLimit` is how
31
+ many rounds autoplay runs before it stops, `0` meaning no limit. `isCarousel` shows
32
+ more than one slide at a time *and* is what lets the ends wrap.
33
+
34
+ ## Two things the markup does not announce
35
+
36
+ **A `slider-navigation-bullet` is a TEMPLATE.** You author one; Mosaic repeats it
37
+ once per slide. Every copy carries the **same id** — three `id="sl-dot-0"` in one
38
+ document — and only the `aria-label` differs, which Mosaic takes from each slide's
39
+ own `title`. Author three bullets and you get three templates. So address a bullet
40
+ by position inside `mosaic-slider-navigation`, never by attrID, and do not expect
41
+ the id to identify one.
42
+
43
+ **An arrow hides itself at the end it cannot pass.** `Slider.js` adds
44
+ `m-slider-arrow--hidden` when the index is 0 (prev) or the last (next), unless the
45
+ slider is a carousel. Autoplay on a non-carousel parks the slider on the last slide,
46
+ so by the time a test reaches for `next` it is legitimately gone — go back to the
47
+ first slide before asking an arrow to move anything. The hidden state is a real
48
+ style state (`___arrow_hidden` in `data/style-states.csv`), so it can be styled.
49
+
50
+ A slide's own `attrID` lands on the inner `.m-slide-content`, not on the
51
+ `<mosaic-slider-slide>` host, and a slide using its image as a background puts an
52
+ `<img>` before that div.
53
+
54
+ ## What it does, measured
55
+
56
+ | | |
57
+ |---|---|
58
+ | advances | reached all 3 slides unaided at the declared 2200ms |
59
+ | arrows | `role="button" tabindex="0"` with a localised `aria-label`; next and prev each moved it |
60
+ | bullets | `role="button" tabindex="0"`, labelled from the slide titles; clicking one goes to that slide |
61
+ | keyboard | focusing a bullet and pressing Enter moves the slider — the runtime wires it, a `tabindex` alone would not |
62
+ | track | `<mosaic-slider-slides>` with `aria-live` — `off` while autoplaying, `polite` when it is not |
63
+ | reduced motion | **it holds still on its own.** Mosaic stops the autoplay; the author does not have to |
64
+
65
+ The carousel (`crossFade`, `isCarousel: "1"`, autoplay off) stayed on slide 0 for
66
+ 2.6s unaided, which is the other half of the same claim: autoplay is the only thing
67
+ that moves a slider by itself.
68
+
69
+ ## Verifying one
70
+
71
+ ```bash
72
+ python tools/verify_slider.py --url https://site/page/ --slider sl-main \
73
+ --autoplay 2200 --csv data/slider-verification.csv
74
+ ```
75
+
76
+ Everything is addressed by position inside the slider, for the reasons above.
77
+ "Which slide is current" is what the tool reads, because `slide` and `crossFade`
78
+ drive transforms and opacities completely differently and that is the one thing
79
+ both agree on.
@@ -2,16 +2,16 @@
2
2
  /**
3
3
  * Refuse to publish a package that does not hold together.
4
4
  *
5
- * node bin/check-release.mjs
5
+ * node scripts/check-release.mjs
6
6
  *
7
7
  * Runs as `preversion` and again as `prepublishOnly`, so a release cannot be cut or
8
8
  * pushed while any of this is false. Exits non-zero with the reason.
9
9
  *
10
- * The version number exists in three places - package.json, the SKILL.md
11
- * frontmatter an agent reads, and the frontmatter each platform template writes on
12
- * install. Nothing keeps them together on its own, and a skill that reports a
13
- * version it is not is worse than one that reports none. `npm version` runs
14
- * sync-version.mjs to write all three; this asserts it worked.
10
+ * The version number exists in four places - package.json, the SKILL.md
11
+ * frontmatter an agent reads, the frontmatter each platform template writes on
12
+ * install, and the Claude plugin manifest. Nothing keeps them together on its own,
13
+ * and a skill that reports a version it is not is worse than one that reports none.
14
+ * `npm version` runs sync-version.mjs to write all four; this asserts it worked.
15
15
  *
16
16
  * It also checks that every path in package.json `files` exists, that the data files
17
17
  * the skill's own claims rest on are present and non-trivial, and that the
@@ -49,6 +49,20 @@ for (const f of fs.readdirSync(platDir).filter((x) => x.endsWith(".json"))) {
49
49
  fail(`${f} still names another skill`);
50
50
  }
51
51
 
52
+ // The Claude plugin manifest carries the version too: the plugin directory lists it
53
+ // and Claude Code pins installs to it, so a stale one serves an old version.
54
+ const manPath = path.join(ROOT, ".claude-plugin", "plugin.json");
55
+ if (!fs.existsSync(manPath)) fail(".claude-plugin/plugin.json missing");
56
+ else {
57
+ const man = JSON.parse(fs.readFileSync(manPath, "utf8"));
58
+ if (man.version !== want) fail(`.claude-plugin/plugin.json version ${man.version} != ${want}`);
59
+ if (man.name !== pkg.name) fail(`.claude-plugin/plugin.json name ${man.name} != ${pkg.name}`);
60
+ }
61
+ // A top-level bin/ goes on the Bash PATH while the plugin is enabled, and claude.ai
62
+ // and Cowork refuse to install a plugin that has one. The npm entry points live in
63
+ // scripts/ for that reason.
64
+ if (fs.existsSync(path.join(ROOT, "bin"))) fail("a top-level bin/ makes claude.ai and Cowork refuse the plugin - keep scripts in scripts/");
65
+
52
66
  // ---------- everything the package claims to ship actually exists -----------
53
67
 
54
68
  // `files` entries are globs, not paths - a directory shipped whole is the mistake
@@ -78,13 +92,16 @@ const counts = {
78
92
  "data/node-verification.csv": 126,
79
93
  "data/style-verification.csv": 98,
80
94
  "data/node-property-verification.csv": 191,
81
- "data/rwd-verification.csv": 731,
82
- "data/browser-verification.csv": 3988,
95
+ "data/rwd-verification.csv": 733,
96
+ "data/browser-verification.csv": 4132,
83
97
  "data/style-state-verification.csv": 52,
84
98
  "data/interaction-verification.csv": 7,
85
99
  "data/data-class-hierarchy.csv": 125,
86
100
  "data/custom-fields-verification.csv": 62,
87
101
  "data/dialog-verification.csv": 19,
102
+ "data/slider-verification.csv": 19,
103
+ "data/navigation-verification.csv": 27,
104
+ "data/page-modal-verification.csv": 19,
88
105
  };
89
106
  for (const [file, expected] of Object.entries(counts)) {
90
107
  if (!fs.existsSync(path.join(ROOT, file))) { fail(`${file} missing`); continue; }
@@ -2,16 +2,17 @@
2
2
  /**
3
3
  * Write package.json's version into every other place that carries one.
4
4
  *
5
- * node bin/sync-version.mjs
5
+ * node scripts/sync-version.mjs
6
6
  *
7
7
  * Runs from the `version` npm lifecycle script, after npm has bumped package.json
8
8
  * and before it makes the commit and the tag - so the tagged commit already has all
9
- * three versions agreeing, and `npm version patch` is the only command anyone has to
9
+ * four versions agreeing, and `npm version patch` is the only command anyone has to
10
10
  * remember.
11
11
  *
12
12
  * Targets:
13
13
  * SKILL.md frontmatter `version:`
14
14
  * assets/templates/platforms/*.json frontmatter.version, where present
15
+ * .claude-plugin/plugin.json version (the Claude plugin directory reads it)
15
16
  *
16
17
  * check-release.mjs asserts afterwards that this worked, so a silent miss here
17
18
  * cannot reach npm.
@@ -60,4 +61,15 @@ for (const f of fs.readdirSync(platDir).filter((x) => x.endsWith(".json"))) {
60
61
  }
61
62
  }
62
63
 
64
+ // Claude plugin manifest - the directory listing and Claude Code's version pinning read it
65
+ const manPath = path.join(ROOT, ".claude-plugin", "plugin.json");
66
+ if (fs.existsSync(manPath)) {
67
+ const man = JSON.parse(fs.readFileSync(manPath, "utf8"));
68
+ if (man.version !== v) {
69
+ man.version = v;
70
+ fs.writeFileSync(manPath, JSON.stringify(man, null, 2) + "\n");
71
+ touched.push(".claude-plugin/plugin.json");
72
+ }
73
+ }
74
+
63
75
  console.log(`synced v${v} into ${touched.length} file(s): ${touched.join(", ") || "none"}`);