mosaic-headless 1.21.1 → 1.22.0

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