mosaic-headless 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/LICENSE +21 -0
  2. package/README.ja.md +94 -0
  3. package/README.md +133 -0
  4. package/README.zh-CN.md +87 -0
  5. package/README.zh-TW.md +87 -0
  6. package/SKILL.md +353 -0
  7. package/assets/templates/platforms/claude-ai.json +34 -0
  8. package/assets/templates/platforms/claude-code.json +28 -0
  9. package/assets/templates/platforms/codex-cli.json +33 -0
  10. package/assets/templates/platforms/continue.json +31 -0
  11. package/assets/templates/platforms/copilot.json +46 -0
  12. package/assets/templates/platforms/cursor.json +36 -0
  13. package/assets/templates/platforms/gemini-cli.json +33 -0
  14. package/assets/templates/platforms/windsurf.json +34 -0
  15. package/bin/check-release.mjs +143 -0
  16. package/bin/install.mjs +452 -0
  17. package/bin/sync-version.mjs +63 -0
  18. package/data/animatable-properties.csv +23 -0
  19. package/data/condition-comparators.csv +13 -0
  20. package/data/condition-subjects.csv +60 -0
  21. package/data/db-columns.csv +207 -0
  22. package/data/default-children.csv +11 -0
  23. package/data/dynamic-variables.csv +75 -0
  24. package/data/element-classes.csv +152 -0
  25. package/data/evaluator-functions.csv +20 -0
  26. package/data/interaction-types.csv +13 -0
  27. package/data/node-properties.csv +182 -0
  28. package/data/node-property-verification.csv +182 -0
  29. package/data/node-types.csv +123 -0
  30. package/data/node-verification.csv +123 -0
  31. package/data/placement-rules.csv +123 -0
  32. package/data/pluggables.csv +208 -0
  33. package/data/property-verification.csv +171 -0
  34. package/data/rest-routes.csv +115 -0
  35. package/data/rwd-verification.csv +570 -0
  36. package/data/style-properties.csv +99 -0
  37. package/data/style-states.csv +54 -0
  38. package/data/style-value-shapes.csv +23 -0
  39. package/data/style-verification.csv +99 -0
  40. package/package.json +59 -0
  41. package/references/data-model.md +95 -0
  42. package/references/design-system.md +118 -0
  43. package/references/dynamic-content.md +113 -0
  44. package/references/failure-modes.md +182 -0
  45. package/references/interactions.md +126 -0
  46. package/references/placement.md +117 -0
  47. package/references/responsive.md +174 -0
  48. package/references/styling.md +172 -0
  49. package/references/templates-and-conditions.md +122 -0
  50. package/references/vs-elementor-gutenberg.md +73 -0
  51. package/references/write-protocol.md +79 -0
  52. package/sites/_moksa.py +1165 -0
  53. package/sites/moksa.json +8685 -0
  54. package/tools/bootstrap_probe_theme.php +68 -0
  55. package/tools/build_all.py +55 -0
  56. package/tools/build_page.py +352 -0
  57. package/tools/build_report.py +221 -0
  58. package/tools/build_site.py +174 -0
  59. package/tools/capture_live.py +130 -0
  60. package/tools/check_placement_predicts.py +72 -0
  61. package/tools/copy_styles.py +204 -0
  62. package/tools/extract_default_children.py +94 -0
  63. package/tools/extract_dynamic_variables.py +104 -0
  64. package/tools/extract_interactions.py +98 -0
  65. package/tools/extract_node_types.py +165 -0
  66. package/tools/extract_placement.py +135 -0
  67. package/tools/extract_pluggables.py +90 -0
  68. package/tools/extract_style_properties.py +163 -0
  69. package/tools/mint_session.php +52 -0
  70. package/tools/probe.py +144 -0
  71. package/tools/sweep_node_properties.py +271 -0
  72. package/tools/sweep_node_types.py +318 -0
  73. package/tools/sweep_properties.py +215 -0
  74. package/tools/sweep_style_properties.py +254 -0
  75. package/tools/theme_export.php +91 -0
  76. package/tools/theme_import.php +113 -0
  77. package/tools/verify_rwd.py +278 -0
@@ -0,0 +1,113 @@
1
+ # Dynamic content: the `@` expression language
2
+
3
+ Mosaic's answer to Elementor's dynamic tags. Verified end to end — every example on
4
+ this page was committed to a live site and read back out of the delivered HTML.
5
+
6
+ ## The syntax you would not guess
7
+
8
+ A bare identifier does **nothing**. `Evaluator::exec()` handles the `Identifier` node
9
+ by returning the literal string `"Identifiers are not used currently"`. So this:
10
+
11
+ ```
12
+ @post.title
13
+ ```
14
+
15
+ is stored happily, renders as the text `@post.title`, and looks like a typo in your
16
+ content rather than a broken feature.
17
+
18
+ The real form is a **function call named `VAR`, taking a `namespace/name` string**:
19
+
20
+ ```
21
+ @VAR('post/title') -> Probe lab
22
+ @VAR('post/id') -> 20
23
+ @VAR('post/permalink') -> https://example.com/probe-lab/
24
+ @VAR('site/name') -> example.com
25
+ ```
26
+
27
+ Expressions compose, and the 19 evaluator functions take `@VAR(...)` results as
28
+ arguments:
29
+
30
+ ```
31
+ @concat('[', @VAR('post/title'), '] #', @VAR('post/id')) -> [Probe lab] #20
32
+ @substr(@VAR('post/title'), 0, 5) -> Probe
33
+ @fallback(@VAR('post/nope'), 'DEFAULTED') -> DEFAULTED
34
+ ```
35
+
36
+ A literal `@` in ordinary text needs escaping as `\@`.
37
+
38
+ `@VAR_RAW('…')` is the same lookup **without HTML-escaping**. The evaluator escapes
39
+ untrusted (request-derived) values at the point of interpolation and carries a
40
+ `{value, trusted}` envelope to do it; `VAR_RAW` opts out of that. Use `VAR` unless you
41
+ are deliberately publishing markup from a trusted field.
42
+
43
+ ## Where an expression goes
44
+
45
+ **Inline in text** — a `wysiwyg-variable` node inside a `text` node, with the
46
+ expression as a plain string in `dynamicCode`:
47
+
48
+ ```json
49
+ {"type": "text", "data": {"tagName": "p"},
50
+ "children": [{"type": "wysiwyg-variable", "data": {"dynamicCode": "@VAR('post/title')"}}]}
51
+ ```
52
+
53
+ `wysiwyg-variable` is one of the seven types that **kill the page** when placed under
54
+ a plain container — it needs a wysiwyg parent, and the sweep recorded it as
55
+ `NodeMResourceFilterFunctionInterface parent is missing`.
56
+
57
+ **On a property** whose validator chain includes `ValidatorDynamicCodeObject` — a
58
+ button's `url`, an image's `src`, and so on. There the value is an object, because a
59
+ literal is also legal in that slot and the object is what disambiguates:
60
+
61
+ ```json
62
+ "url": {"v": "@VAR('post/permalink')"}
63
+ ```
64
+
65
+ Two different shapes for the same language, and which one applies is decided by the
66
+ property's validator chain in `data/node-properties.csv`: **`ValidatorDynamicCode`
67
+ means a bare string, `ValidatorDynamicCodeObject` means `{"v": "…"}`**.
68
+
69
+ ## The variable namespace
70
+
71
+ `data/dynamic-variables.csv` — 74 variables across 11 namespaces, each row carrying
72
+ the exact `@VAR('…')` expression to paste:
73
+
74
+ | namespace | n | what it needs to resolve |
75
+ |---|---|---|
76
+ | `post` | 22 | a post/page context — `title`, `id`, `permalink`, `content`, `featuredImage`, `publish_date`, `author_name`, `modified_timestamp`, … |
77
+ | `user` | 13 | the current visitor — `displayName`, `email`, `loggedin`, `user_registered_date`, … |
78
+ | `site` | 7 | always — `name`, `url`, `description`, `admin_email`, `wpurl` |
79
+ | `author` | 7 | a post context |
80
+ | `menu` | 7 | inside a menu loop — `href`, `title`, `hasChildren` |
81
+ | `request` | 4 | always — `ip`, `page_url`, `referrer`, `user_agent` |
82
+ | `slider` | 4 | inside a slider |
83
+ | `wordpress_search` | 4 | a search results page — `searchQuery`, `resultCount` |
84
+ | `wordpress_archive` | 3 | an archive page |
85
+ | `form` | 2 | inside a form action |
86
+ | `__current_theme` | 1 | always |
87
+ | `row` | — | inside a loop; its schema is declared per loop source, not statically |
88
+
89
+ **An unresolvable name is an empty string, never an error.** `@VAR('nope/nothing')`
90
+ renders as nothing at all, and so does a real name in the wrong context — a
91
+ `post/title` on a page with no post context is indistinguishable from a typo. That is
92
+ what `@fallback()` is for, and why a blank spot on a page is the symptom to look for
93
+ rather than a message in a log.
94
+
95
+ The name separator is `/` (`TemplatingContextVariable::VARIABLE_SEPARATOR`), and the
96
+ namespace is everything before the first one.
97
+
98
+ ## The functions
99
+
100
+ `data/evaluator-functions.csv` — 19 of them with their arity:
101
+
102
+ ```
103
+ abs(1) round(1) round_up(2) calc(1) sum(…) avg(…) numeric
104
+ concat(…) substr(3) excerpt(4) remove_html(1) text
105
+ esc_attr(1) esc_html(1) json_encode(1) escaping
106
+ date(3) dates
107
+ fallback(…) defaulting
108
+ attachment(2) find_image(2) find_link(2) find_video(2) media
109
+ ```
110
+
111
+ `sum`, `avg`, `concat` and `fallback` are variadic. `date()`'s timestamp argument is a
112
+ true GMT Unix timestamp — pair it with `post/publish_timestamp_gmt`, not
113
+ `publish_timestamp`, unless you want the local-time value re-interpreted as UTC.
@@ -0,0 +1,182 @@
1
+ # How Mosaic fails, measured
2
+
3
+ ## `$wpdb->insert()` casts any column named `ID` to an integer
4
+
5
+ WordPress hardcodes `'ID' => '%d'` in `$wpdb->field_types`, because `wp_posts.ID` is
6
+ an integer. Mosaic's `ID` columns are `varchar(36)` — UUIDs, and for breakpoints the
7
+ literals `_t` and `_m`.
8
+
9
+ So `$wpdb->insert($table, $row)` on a Mosaic table stores `_t` as `0`. It happened to
10
+ fail loudly here, because `_m` also becomes `0` and collides on the composite primary
11
+ key — on a table where only one row was affected it would have been silent.
12
+
13
+ Pass an explicit format array on every insert:
14
+
15
+ ```php
16
+ $wpdb->insert($table, $row, array_fill(0, count($row), '%s'));
17
+ ```
18
+
19
+ ## WP-CLI eats `--flags` before your script sees them
20
+
21
+ `wp eval-file script.php --active` fails with *unknown --active parameter*: WP-CLI
22
+ parses anything `--`-prefixed as its own option. Script arguments must be bare
23
+ keywords — `wp eval-file script.php active`.
24
+
25
+ ## An invalid `ordering` orphans the node, silently
26
+
27
+ `ordering` is a fractional-index STRING. Send something that is not one and the
28
+ commit returns **no exception** — and the row is stored with **both `ordering` and
29
+ `parentID` blanked**. The node has no parent, so it never renders, and anything you
30
+ were measuring through it reads as a clean negative.
31
+
32
+ Measured side by side in one commit, three sibling probes:
33
+
34
+ ```
35
+ ordering "a6" -> stored: ordering=a6 parentID=a2dd9602-... renders
36
+ ordering "z000" -> stored: ordering='' parentID='' orphan
37
+ ordering "z001" -> stored: ordering='' parentID='' orphan
38
+ ```
39
+
40
+ This cost a whole style-property sweep: 77 probes committed without error, none
41
+ rendered, and all 77 properties came back ABSENT — including `color` and
42
+ `paddingTop`, which the entire site is built on. The result looked like a finding.
43
+ It was the tool being broken.
44
+
45
+ Use `ordering_for()` in `tools/build_page.py`; never format your own. And when a
46
+ sweep returns a suspiciously total failure, check that the probes rendered before
47
+ believing the measurement — `sweep_style_properties.py` now exits rather than
48
+ reporting a run in which nothing rendered.
49
+
50
+ ## Duplicate `ordering` among siblings drops nodes, silently
51
+
52
+ `ordering_for()` in `build_page.py` wraps after 62 entries, so `ordering_for(i % 62)`
53
+ hands two siblings the same index. Mosaic keeps one and discards the other without an
54
+ exception. A sweep of 79 probes left 39 nodes in the database and still printed a
55
+ result table.
56
+
57
+ For more than 62 siblings, generate your own monotonic index - `"a" + ALPHA[i // 62]
58
+ + ALPHA[i % 62]` gives 3844 lexicographically ordered slots.
59
+
60
+ ## A sweep must refuse a contaminated page
61
+
62
+ Probe ids from separate runs share an id space, and two runs can give the same id to
63
+ different node types - `np-101` was a `<select>` from one run and a `<div>` from the
64
+ next, on the same page. Every number read off that page was meaningless.
65
+
66
+ Worse, the cleanup query was `LIKE "%np-0%"`, which silently misses every id from
67
+ `np-100` up, so "0 remaining" was itself wrong. Match the stored shape:
68
+ `LIKE '%"attrID":"np-%'`.
69
+
70
+ `sweep_node_properties.py` now exits unless the page carries exactly the probes this
71
+ run planned, allowing for the ones whose own property relocates them out of the tree.
72
+
73
+ ## heal() owns the body's children
74
+
75
+ Do not parent anything directly to a `body` node. `heal()` rebuilds the
76
+ body > three-div skeleton whenever it decides one is missing, and probes hung
77
+ straight off the body do not survive it. Attach to a div inside the body instead.
78
+
79
+ Every failure below was produced on purpose, on a live install, by the sweep in
80
+ `tools/sweep_node_types.py`. None of it is inferred from source.
81
+
82
+ ## Mosaic does not validate placement at commit time
83
+
84
+ This is the fact that shapes everything else. Mosaic's commit endpoint will happily
85
+ store a node whose parent makes no structural sense. The rejection happens later, at
86
+ **render** — and it takes the entire page with it.
87
+
88
+ Placing `accordion-item` directly under a `div`:
89
+
90
+ ```
91
+ commit -> HTTP 200, syncResponseEnvelopes with action "create". The row is in the DB.
92
+ render -> HTTP 200, 54 bytes, body: "AccordionElementMResource instance required"
93
+ ```
94
+
95
+ Not a 500. Not a WordPress error page. A **200 with a plain string where the site used
96
+ to be**. Three of the 74 free types do this from a plain `div` parent:
97
+
98
+ | type | what the dead page says |
99
+ |---|---|
100
+ | `accordion-item` | `AccordionElementMResource instance required` |
101
+ | `accordion-content` | `AccordionItemElementMResource instance required` |
102
+ | `wysiwyg-variable` | `NodeMResourceFilterFunctionInterface parent is missing` |
103
+
104
+ The consequence for anything automated: **a successful commit is not evidence of a
105
+ working page.** Fetch the page and check its size after writing. A monitor that only
106
+ watches status codes will report a healthy site that is serving 54 bytes.
107
+
108
+ It also means a batch write is dangerous in a way it is not in Elementor or Gutenberg:
109
+ one bad node does not degrade its own corner of the page, it deletes the page. Write
110
+ one subtree, verify, then write the next.
111
+
112
+ ## Some types kill the commit request itself
113
+
114
+ Five free types return **HTTP 500** from `/commit` when placed under a `div`:
115
+
116
+ ```
117
+ component-instance loop loop-items loop-pagination-numbers loop-pagination-number
118
+ ```
119
+
120
+ These are the types that require a resolved context — a component to instantiate, a
121
+ query to iterate — and the constructor throws before Mosaic can turn the problem into
122
+ a normal validation response. Nothing is written, so a 500 here is *safer* than the
123
+ silent 200 above, but it is still a PHP fatal in the error log rather than an
124
+ error message you can show a user.
125
+
126
+ `document` produced a 502 on one run and a 500 on another: the same fatal, sometimes
127
+ surfacing as a gateway timeout instead. Treat 5xx from `/commit` as one class.
128
+
129
+ ## Validator rejections arrive as HTTP 200
130
+
131
+ Separate from both cases above: when Mosaic *does* reject a value cleanly, it answers
132
+ `RESTJSONExceptionEnvelope` — HTTP **200** with an `exceptions` array in the body.
133
+
134
+ ```python
135
+ resp = commit(...) # 200
136
+ resp["response"]["exceptions"] # <- the actual verdict lives here
137
+ ```
138
+
139
+ So there are three distinct outcomes and only one of them changes the status code:
140
+
141
+ | what happened | status | how you detect it |
142
+ |---|---|---|
143
+ | clean validator rejection | 200 | `exceptions` in the body |
144
+ | PHP fatal during commit | 500/502 | status code |
145
+ | structurally invalid node accepted | 200 | **nothing, until you fetch the page** |
146
+
147
+ ## Commit has side effects beyond the rows you sent
148
+
149
+ The first commit against a fresh theme came back having created things nobody asked
150
+ for:
151
+
152
+ ```
153
+ create breakpoint _t
154
+ create breakpoint _m
155
+ create collection 9083a14b-…
156
+ create collectionMode 9a674a81-…
157
+ create collectionSkin e4beb80a-…
158
+ ```
159
+
160
+ `heal()` runs as part of the commit path, so the response's `syncResponseEnvelopes`
161
+ can contain resources from managers you never touched. Read the whole envelope list
162
+ and take every revision in it — assuming the response only describes your own writes
163
+ will leave you holding stale revisions for the rest of the session.
164
+
165
+ Composite types heal aggressively too: a naive batch that placed all 74 types once
166
+ produced **802 node rows**, because types like `accordion` and `navbar` build their
167
+ required children on commit.
168
+
169
+ ## `modified_gmt` is server-assigned
170
+
171
+ A record committed with `"modified_gmt": "2026-09-05 17:00:00"` came back stored as
172
+ `16:59:16`. The server overwrites it. Do not use a value you sent as a local cache key.
173
+
174
+ ## Reproducing all of this
175
+
176
+ ```bash
177
+ wp eval-file tools/bootstrap_probe_theme.php # licence-free scratch theme
178
+ python tools/sweep_node_types.py --config sweep.json --setup
179
+ python tools/sweep_node_types.py --config sweep.json --sweep
180
+ ```
181
+
182
+ The sweep is destructive by design and must only be pointed at a scratch site.
@@ -0,0 +1,126 @@
1
+ # Interactions: Mosaic's JavaScript animation system
2
+
3
+ There are two ways to animate in Mosaic and they are completely separate.
4
+
5
+ **The CSS path** — a `transition` array plus a `hover` (or any other) state — is fully
6
+ verified and is what `sites/_moksa.py` uses. It covers hover, focus and
7
+ every node-type state in `data/style-states.csv`. Reach for it first;
8
+ `references/styling.md` has the shapes.
9
+
10
+ **The interaction path** is this document: scroll-driven and event-driven animation
11
+ with keyframes, run by Mosaic's own JavaScript. It is **partially verified**, and this
12
+ page is explicit about where the verified part ends.
13
+
14
+ ## What is confirmed
15
+
16
+ The envelope, the trigger types and the action/timeline structure all reach the
17
+ browser. A node carries:
18
+
19
+ ```json
20
+ "interactions": [{
21
+ "type": "scrollIntoView",
22
+ "scrollIntoViewOptions": {
23
+ "name": "Reveal",
24
+ "ID": "<uuid>",
25
+ "actionSlots": {
26
+ "scrollIntoView": {
27
+ "actions": [{
28
+ "type": "animation",
29
+ "uuid": "<uuid>",
30
+ "animationOptions": {
31
+ "propertyMetas": [ … ],
32
+ "initial": { … },
33
+ "keyframes": [{"uuid": "<uuid>",
34
+ "progressData": {"delay": 0, "duration": 100}}]
35
+ }
36
+ }]
37
+ }
38
+ }
39
+ }
40
+ }]
41
+ ```
42
+
43
+ and the page then emits, in a `<script>` before the frontend bundle:
44
+
45
+ ```js
46
+ var mosaicInteractionTypes={"progress":[{"id":"scrollIntoView",
47
+ "defaultSettings":{"allowBackward":"1","smoothing":"60","startWhen":"middleOfTheScreen",
48
+ "startOffset":"0px","endWhen":"middleOfTheScreen","endOffset":"0px"},
49
+ "supportsLivePreview":true,"timelineKeys":["_"]}]};
50
+ var mosaicInteractions=[{"type":"scrollIntoView","triggerSelector":".M_EL3",
51
+ "action":{"scrollIntoView":{"actions":[{"type":"animation","data":{},
52
+ "animationOptions":{"timelines":{"_":{"keyframes":[
53
+ {"progressData":{"delay":0,"duration":100},"easing":"ease"}]}}}}]}}}];
54
+ ```
55
+
56
+ That payload is the oracle for anything still unknown: commit a shape, fetch the page,
57
+ read `var mosaicInteractions`. Whatever survives into it is what Mosaic accepted.
58
+
59
+ Confirmed from it:
60
+
61
+ - **The action-slot key is the interaction's own ID** when the type declares no
62
+ explicit slots (`TimedInteractionTypeFactory` falls back to `$this->reader->getId()`).
63
+ - **The array inside a slot is `actions`**, not the slot name.
64
+ - **The keyframe array is `keyframes`**, not the timeline key — the timeline key
65
+ (`_` for a single-timeline type) is applied by the server, which reshapes
66
+ `animationOptions` into `{"timelines": {"_": {"keyframes": [...]}}}`.
67
+ - **`progressData` uses floats 0–100**, not durations, for progress-family triggers —
68
+ it is a percentage of the scroll range, not milliseconds.
69
+ - **`easing` is defaulted to `ease`** by the server.
70
+
71
+ ## What is NOT solved
72
+
73
+ **Property binding.** `propertyMetas` and per-keyframe `properties` did not survive
74
+ into the payload in any shape tried: flat array of metas, metas nested one level
75
+ deeper (which made the whole interaction vanish), properties as an object, properties
76
+ as an array. The keyframes arrive with timing but with nothing to animate.
77
+
78
+ Do not ship an interaction animation on the strength of this page. Use the CSS path,
79
+ or drive the editor once by hand and read the stored row back out of `wp_mosaic_nodes`
80
+ — that row is the authoritative example, and one look at it would settle the shape.
81
+
82
+ ## The failure mode that makes this hard
83
+
84
+ **Invalid sub-structures are silently pruned.** Committing the full interaction above
85
+ returned HTTP 200, no `exceptions`, and a normal `create` envelope — and the row
86
+ stored was:
87
+
88
+ ```json
89
+ [{"type":"scrollIntoView","scrollIntoViewOptions":{"name":"Reveal"},"uuid":"…"}]
90
+ ```
91
+
92
+ `ID`, `actionSlots`, the action and the whole animation were dropped without a word.
93
+ Mosaic also *added* a `uuid` of its own. So on this data model:
94
+
95
+ - a successful commit says nothing about whether your structure was understood;
96
+ - **read the row back** (`GET …/masterDocumentInstance/<masterID>`) and compare it to
97
+ what you sent — the diff is the error message Mosaic never gives you.
98
+
99
+ That check is worth applying to any deeply-nested Mosaic write, not just interactions.
100
+
101
+ ## The surface, extracted
102
+
103
+ `data/interaction-types.csv` — 12 trigger types in two families:
104
+
105
+ | family | how it runs | types |
106
+ |---|---|---|
107
+ | `timed` | its own clock, fired by an event | `pageLoad`, `click`, `hover`, `formSubmit`, `tabChange`, `slideChange`, `dropdownChange`, `accordionItemChange`, `elementScrollIntoView`, `scrollDirectionChange` |
108
+ | `progress` | driven by a scalar, scrubbable both ways | `scrollIntoView`, `pointerMove` |
109
+
110
+ `data/animatable-properties.csv` — the 22 properties a keyframe can drive. **This is
111
+ not the same set as the 98 style properties**, and ten of them animate through a CSS
112
+ custom property rather than the property itself:
113
+
114
+ ```
115
+ transform translateX/Y/Z (--mosaic-translate-*) scale, scaleX/Y/Z (--mosaic-scale*)
116
+ rotateX/Y/Z (--mosaic-rotate-*) opacity color backgroundColor borderColor
117
+ boxShadow textShadow width height filter backdropFilter transformOrigin
118
+ ```
119
+
120
+ Animating through `--mosaic-translate-y` rather than `translate` is why a Mosaic
121
+ interaction can move an element that already has a CSS `translate` set: the two
122
+ compose instead of one clobbering the other.
123
+
124
+ `data/pluggables.csv` lists the 11 non-animation action types an interaction can run —
125
+ `link`, `script`, `setCookie`, `player*`, `interaction*` — for triggering behaviour
126
+ rather than motion.
@@ -0,0 +1,117 @@
1
+ # Placement: the rule the API will not enforce for you
2
+
3
+ Mosaic decides whether an element may sit inside another element in **two** places,
4
+ and neither of them is the REST commit endpoint.
5
+
6
+ ```
7
+ canBeParentFor($childFactory) on the PARENT type - "will I take this child?"
8
+ canBeNestedChildFor($target, $br) on the CHILD type - "am I allowed in this ancestry?"
9
+ ```
10
+
11
+ The visual editor calls both before it lets you drop anything. `POST …/commit` calls
12
+ neither. A node committed into a parent that would have refused it is stored, returns
13
+ HTTP 200 with a normal `syncResponseEnvelopes`, and the **public page then dies**,
14
+ serving a bare string like `AccordionElementMResource instance required` as a 200 with
15
+ a ~54 byte body.
16
+
17
+ That is the whole reason this table exists. Writing Mosaic headlessly means being the
18
+ component that checks placement, because nothing downstream will.
19
+
20
+ ## The parent side: `data/placement-rules.csv`
21
+
22
+ One row per node type, extracted from every `canBeParentFor` in the source (122 types):
23
+
24
+ | rule | count | meaning |
25
+ |---|---|---|
26
+ | `none` | 59 | leaf. Refuses every child — the default on `NodeTypeFactoryAbstract`. |
27
+ | `any` | 34 | takes anything. `div`, `body`, `section`, `button`, `menu`, `slider-slide`, `tabs-tab-pane`, … |
28
+ | `allow` | 26 | takes only the types in `allowed_children`. |
29
+ | `complex` | 3 | the body is not a plain `instanceof` chain — `fieldset`, `select-input`, `styleguide-entry-content-text-quote`. Read the file named in `declared_in`. |
30
+
31
+ The `allow` rows are the component families, and they are strict. A few worth knowing
32
+ by heart because they are the ones people get wrong:
33
+
34
+ ```
35
+ accordion -> accordion-item | accordion-loop-items
36
+ accordion-item -> accordion-item | accordion-loop-items
37
+ list -> list-item | list-loop-items
38
+ tabs -> tabs-content | tabs-menu
39
+ tabs-menu -> tabs-loop-tabs | tabs-tab
40
+ tabs-content -> tabs-loop-tab-panes | tabs-tab-pane
41
+ slider-slides -> slider-loop-slides | slider-slide
42
+ slider-navigation -> slider-navigation-bullet
43
+ form-wrapper -> form | success-screen
44
+ submit-button -> submit-label | submit-loading
45
+ loop-items -> loop-item
46
+ wp-menu -> list-item
47
+ ```
48
+
49
+ Note `accordion-item -> accordion-item`: an accordion item's children are further
50
+ items, not arbitrary content. The content goes in `accordion-title` and
51
+ `accordion-content`, both of which are `rule = any`.
52
+
53
+ ## The child side: `nested_rule`
54
+
55
+ **55 of the 122 types also implement `canBeNestedChildFor`** — a runtime condition on
56
+ the *ancestry*, not just the immediate parent. A static table cannot resolve these,
57
+ so the CSV flags them and names the file.
58
+
59
+ `accordion-item` is the worked example:
60
+
61
+ ```php
62
+ // legal only if the target is an accordion / accordion-loop-items,
63
+ // or an accordion appears somewhere up the branch
64
+ if (!$inAccordion && !$elementFactoryNode->hasFactoryOnBranch(AccordionElementTypeFactory::class)) {
65
+ return false;
66
+ }
67
+ ```
68
+
69
+ `div` says `any`, so the parent side waves `accordion-item` through. The child side
70
+ would have refused — but only the editor asks it. Commit does not, and the page dies.
71
+ This is exactly the measured `BROKE_PAGE` case.
72
+
73
+ ## The rule to write into your own code
74
+
75
+ Before committing a node of type `C` under a parent of type `P`:
76
+
77
+ 1. Look up `P` in `placement-rules.csv`. If `rule` is `none`, stop. If `allow`, `C`
78
+ must be in `allowed_children`. If `complex`, read `declared_in`.
79
+ 2. Look up `C`. If `nested_rule` is `yes`, its required ancestry must genuinely be
80
+ present — the parent chain, not just the immediate parent.
81
+ 3. Commit, then **fetch the page and check its size**. Step 3 is not optional; steps
82
+ 1 and 2 are a static approximation of two runtime methods.
83
+
84
+ Types with `rule = any` are the safe scaffolding: `div`, `section`, `body`,
85
+ `loop-item`, `tabs-tab-pane`, `slider-slide`, `styleguide-entry-content`. Build with
86
+ those and place specialised children only inside the family that declares them.
87
+
88
+ ## What this table does NOT tell you
89
+
90
+ It does not predict which types are unsafe to drop into a plain container. That was
91
+ tested rather than assumed, and it failed:
92
+
93
+ ```
94
+ predictor tp / fp / fn precision recall
95
+ nested_rule == yes 9 / 46 / 13 0.16 0.41
96
+ rule == allow 11 / 15 / 11 0.42 0.50
97
+ rule in (allow, complex) 11 / 18 / 11 0.38 0.50
98
+ rule == allow AND nested_rule 5 / 12 / 17 0.29 0.23
99
+ rule == allow OR nested_rule 15 / 49 / 7 0.23 0.68
100
+ ```
101
+
102
+ 22 of the 122 types break the page or fault the commit when placed under a bare `div`,
103
+ and they are spread across every rule value - 11 are `allow`, 6 are `none`, 5 are
104
+ `any`. No flag in this table separates them, and the best combination still misses a
105
+ third of them while flagging 49 types that are perfectly fine.
106
+
107
+ So the two questions are different, and only one of them is settled by source:
108
+
109
+ | question | authority |
110
+ |---|---|
111
+ | which children does parent P accept? | `placement-rules.csv` - reliable, this is literally `canBeParentFor` |
112
+ | is child C safe under a plain container? | `node-verification.csv` - **measured**, nothing else predicts it |
113
+
114
+ Reach for the measured table when you are deciding what to build with, and this one
115
+ when you are deciding what may go inside what. `tools/check_placement_predicts.py`
116
+ re-scores the numbers above after any re-sweep, so the claim stays honest if the
117
+ plugin changes.