mosaic-headless 1.32.0 → 1.32.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/SKILL.md CHANGED
@@ -5,7 +5,7 @@ description: |
5
5
  license: "MIT"
6
6
  metadata:
7
7
  author: "moksa (https://moksaweb.com)"
8
- version: "1.32.0"
8
+ version: "1.32.2"
9
9
  homepage: "https://github.com/Moksa1123/mosaic-headless"
10
10
  ---
11
11
 
@@ -490,10 +490,12 @@ so the pattern is in the data, not just in this paragraph.
490
490
  properties whose descriptor exists, and those are created during sync on a
491
491
  `DataMeta` that starts empty. Animate with the CSS state path, which is fully
492
492
  verified: 36 of 37 probed states compile to exactly the promised selector.
493
- - Committing a **condition** has not been driven end to end. The grammar in
494
- `references/templates-and-conditions.md` is read from source and from the live
495
- metas; an element carrying one rendered as hidden, which is consistent with the
496
- condition evaluating false but is not proof the shape was understood.
493
+ - An element **condition** puts its comparator INSIDE the subject's options
494
+ (`subject.<type>Options.comparator`). One level up is a PHP fatal that blanks the
495
+ whole page; so is `{"v": …}` as a `variable` subject's `dynamicCode` (a plain
496
+ string there). A server-condition `regexp` needs delimiters (`/./`) - `.` never
497
+ matches and the element silently disappears. Driven end to end on a client build;
498
+ references/templates-and-conditions.md
497
499
 
498
500
  ## Orient yourself in this order
499
501
 
@@ -821,6 +823,19 @@ post — `build_all.py` resets first for that reason.
821
823
  default - it is a `<span>`, a label rather than a control. Measured all three.
822
824
  `menu-link` and `wysiwyg-link` still render `<span>` without a `url`.
823
825
  - **`text` renders as `<div>` by default** — set `tagName` for `<h1>`, `<p>` and so on.
826
+ - **`menu-link` is `width: fit-content` by default** (`.m-menu-link{display:flex;gap:4px;
827
+ padding:4px 8px;width:fit-content;…}`). Used as a cell of a button row or a grid -
828
+ a phone contact bar on `2fr 1fr 1fr` - its background hugs the text and the cell
829
+ stays empty around it, whatever `display` or `justify-content` you give it. Set
830
+ `width: 100%` on it (reported from a client build; the rule read from 1.0.10's
831
+ `style.css`). The same rule's `align-items:center` matters when a `menu-link`
832
+ wraps a whole card: set `alignItems: stretch` and `textAlign: left` on it.
833
+ - **`customDeclarations` is all or nothing, and its unit list is short.** The
834
+ validator parses every declaration and drops the WHOLE block if one value fails.
835
+ `!important` fails, and so does any unit outside `ValidatorCustomDeclarations`'s
836
+ list - `dvh`, `svh`, `lvh`, `dvw`, container units: `height: 100dvh` takes every
837
+ other line in the block with it, silently. The structured `height` / `maxHeight`
838
+ fields do accept `dvh`.
824
839
  - **`youtube` renders `<mosaic-youtube><iframe>` with no `title`** - the iframe gets
825
840
  only `loading`, `src`, `allow` and an id, so an accessible name has to be added
826
841
  some other way. `youtubeUrl` is `{"v": "https://www.youtube.com/watch?v=…"}`;
@@ -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.32.0"
23
+ "version": "1.32.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.32.0"
23
+ "version": "1.32.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.32.0"
23
+ "version": "1.32.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.32.0"
23
+ "version": "1.32.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.32.0"
23
+ "version": "1.32.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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mosaic-headless",
3
- "version": "1.32.0",
3
+ "version": "1.32.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",
@@ -60,7 +60,40 @@ Three things learned the hard way:
60
60
  goes away.
61
61
  - Look up the ID in `data/variants.csv` by `name`. Several rows share a
62
62
  name (`Button` appears four times) because sub-components have their own metas — the
63
- `selectors` and `parent` columns tell them apart.
63
+ `selectors` and `parent` columns tell them apart. `Item` is the trap: the accordion,
64
+ loop and list items are all `Item` and all have a parent, so "the parent-less one"
65
+ finds none. `build_page.py` takes a theme variant key as the catalog id, the
66
+ `class_name` (`m-loop-item`) or a name, and refuses a name that matches more than
67
+ one row.
68
+
69
+ More from a client build on 1.0.10:
70
+
71
+ - **`HTML` and `Body` emit `html{…}` and `body{…}`**, and `customDeclarations` works
72
+ on them - the place for page-wide settings that have no node.
73
+ - **The loop pagination states work on the variants**:
74
+ `____loop_pagination_number--active` on `Number`,
75
+ `___loop_pagination_button--disabled` on `Pagination button`.
76
+ - **Variant CSS is printed early in `<head>`.** At equal specificity, a plugin's
77
+ `<link>` stylesheet and a page's own `<style>` both come later and win. Moving a
78
+ rule from a plugin stylesheet into a variant can therefore bring back a page rule
79
+ the plugin had been overriding.
80
+ - **The Gutenberg variants are scoped to block markup.** They match
81
+ `:is(.entry-content, .wp-block-post-content) X`, and `List` matches only
82
+ `ul.wp-block-list` - a list written by Rank Math's FAQ block or in raw HTML has no
83
+ such class and is not styled. A `wpContent` node renders
84
+ `.m-post-content > .wp-site-blocks > .entry-content`.
85
+
86
+ ## Universal classes
87
+
88
+ A universal class is the theme-global class a user names (the editor's "classes"),
89
+ committed on the master document under the key `universalClass` with
90
+ `data: {name, label, states}` - `states` is the same structure as a variant's. A node
91
+ takes it through `style.universalClasses`, a map `{<classID>: <classID>}` (a map to a
92
+ universal SUB class ID applies that sub class instead; `ElementMResourceStyle.php`).
93
+ The emitted class is the `name` (sanitized, any `m-` prefix dropped - the
94
+ class-name grammar is in SKILL.md), and its CSS is printed only
95
+ on a page where some node uses it. `status: "delete"` removes one - unlike a variant,
96
+ a universal class is not catalog-backed.
64
97
 
65
98
  ## Collection variables: the design tokens
66
99
 
@@ -213,18 +246,34 @@ only the slots that differ:
213
246
  "cs-name": {"text": "…"}}}
214
247
  ```
215
248
 
216
- **An instance's ids are prefixed with the instance's own attrID.** The definition's
217
- `cs-card` becomes `cs-0-cs-card`, `cs-1-cs-card`, and so on, so the definition's
218
- attrID is a SUFFIX in the delivered page and `getElementById` on it finds nothing.
219
- Address instances with `[id$="-cs-card"]`.
249
+ **Mosaic copies the definition's ids into every instance verbatim; `build_site.py`
250
+ is what makes them unique.** Mosaic has no id-prefixing of its own - an element's
251
+ id is its `attrID` (`ElementAttrID.php`), and every instance renders the
252
+ definition's nodes with the definition's attrIDs, so two instances put every id
253
+ into the document twice. `build_site.py`'s override pass writes an `attrID` of
254
+ `<instance attrID>-<definition attrID>` into each instance's override node: the
255
+ definition's `cs-card` becomes `cs-0-cs-card`, `cs-1-cs-card`, and so on, so the
256
+ definition's attrID is a SUFFIX in the delivered page and `getElementById` on it
257
+ finds nothing. Address instances with `[id$="-cs-card"]`.
258
+
259
+ Until 1.32.1 that pass ran only for instances that HAD overrides, so an instance
260
+ written with none - a contact block placed as-is in a footer and in a page body -
261
+ kept the definition's ids, and a client build found every one of them twice. It runs
262
+ for every instance now; measured on the demo, two override-less instances gave
263
+ `ft-a-cs-card` and `ft-b-cs-card` and no duplicates. A tool other than
264
+ `build_site.py` that writes instances has to do the same, or accept duplicate ids.
220
265
 
221
266
  Four more things a build that leans on components runs into:
222
267
 
223
- - **The instance element itself carries no id.** Its `attrID` exists only as that
224
- prefix, so a selector aimed at the instance's own attrID finds nothing - aim at
225
- `<instance>-<definition id>`. Two instances given the SAME attrID (or none) get
226
- the same prefix and so duplicate every id inside; give each a distinct attrID. A
227
- page with two `case-card` instances, `cs-0` and `cs-1`, measured zero duplicates.
268
+ - **The instance element itself carries no id.** Its `attrID` exists only as the
269
+ prefix `build_site.py` writes, so a selector aimed at the instance's own attrID
270
+ finds nothing - aim at `<instance>-<definition id>`. Two instances given the SAME
271
+ attrID (or none) get the same prefix and so duplicate every id inside; give each a
272
+ distinct attrID.
273
+ - **A loop repeats ids too, and that one is Mosaic's.** Every `loop-item` renders
274
+ the same attrIDs, so a list of six cards carries six of each id (reported from a
275
+ client build). `ElementAttrID.php` evaluates an attrID per loop item, which is the
276
+ hook for making them unique; that has not been measured here.
228
277
  - **A component's id is its name.** `build_site.py` derives it with
229
278
  `uuid5(COMPONENT_NAMESPACE, name)` so rebuilds update the same row; renaming the
230
279
  key in `site["components"]` therefore creates a new component and leaves the old
@@ -604,6 +604,11 @@ document. Lay the forms out by positioning the WINDOW instead, which leaves
604
604
  | corner notice | no padding | `top:90px; right:24px`, a small `max-width` |
605
605
  | full takeover | no padding | `top:0; right:0; bottom:0; left:0`, `max-width:none` |
606
606
 
607
+ "No padding" has to be written: the host starts with the browser's own
608
+ `dialog { padding: 1em }`, because Mosaic's `.m-modal` rule resets inset, margin and
609
+ border but not padding. A client build measured a drawer's host at `padding:
610
+ 14.408px` - 1em of its fluid body size - with no modal variant on the page.
611
+
607
612
  `transform` is a structured value, so the centring pull-back is two entries
608
613
  (`translateX`, `translateY`), not a string — `references/styling.md` has the shape.
609
614
 
@@ -191,7 +191,21 @@ value `{"v": "@LOOP('post/work_category', 1, 'term_id')"}`. A Meta Box checkbox
191
191
  `settings: {"key": "work_featured"}` and a `text` comparator
192
192
  `{"operator": "equals", "settings": {"value": "1"}}` - six posts back, in order.
193
193
 
194
- `orderByClauses` accepts `random`: `{"type": "random", "uuid": "<uuid>",
194
+ `orderByClauses` is a flat list of clauses, each `{uuid, type, <type>Options:
195
+ {settings}}` (`ValidatorOrderBy.php`), under `wpPostType<Type>Options`. Sorting by a
196
+ post column:
197
+
198
+ ```json
199
+ [{"type": "postVariable", "uuid": "<uuid>", "postVariableOptions": {"settings": {
200
+ "key": "post_date", "cast": "auto", "direction": "ASC"}}}]
201
+ ```
202
+
203
+ **Every settings field is required, `cast` included.** The validator runs each of the
204
+ type's fields and rejects the clause on the first missing one, and a rejected clause
205
+ is stored as `[]` - no error, and the loop falls back to the default newest-first
206
+ order. Reported from a client build on 1.0.10; the validator read from source.
207
+
208
+ `orderByClauses` also accepts `random`: `{"type": "random", "uuid": "<uuid>",
195
209
  "randomOptions": {"settings": {}}}`, a different order on every uncached render. Under
196
210
  a page cache that is one order per cache lifetime, not per visitor.
197
211
 
@@ -179,7 +179,9 @@ which are `SingleTransformTypeFactory` and do take `{"value": …}`.
179
179
  rewrites stored rows, and `build_page.py` still accepts the old spelling in a spec)
180
180
  accepts raw CSS text and emits it verbatim into the rule:
181
181
  `"customDeclarations": "outline: 2px dashed rgb(7,8,9);"` compiled through
182
- unchanged. It is the fallback for anything whose structured shape you have not pinned
182
+ unchanged. Every value is still validated, and one bad value - `!important`, a `dvh`
183
+ or other unit outside the validator's list - drops the whole block silently. It is the
184
+ fallback for anything whose structured shape you have not pinned
183
185
  down — at the cost of bypassing the collection-variable layer entirely.
184
186
 
185
187
  `tools/build_page.py` wraps the five shapes above as `radius`, `shadow`,
@@ -88,11 +88,12 @@ interaction gating and form actions — four contexts, one grammar.
88
88
  "uuid": "<uuid>",
89
89
  "type": "serverCondition",
90
90
  "serverConditionOptions": {
91
- "subject": {"type": "httpGet",
92
- "httpGetOptions": {"settings": {"name": "show"}}},
93
- "comparator": {"type": "text",
94
- "textOptions": {"operator": "equals",
95
- "settings": {"value": "yes"}}}
91
+ "subject": {"type": "httpGet",
92
+ "httpGetOptions": {
93
+ "settings": {"name": "show"},
94
+ "comparator": {"type": "text", // INSIDE <type>Options
95
+ "textOptions": {"operator": "equals",
96
+ "settings": {"value": "yes"}}}}}
96
97
  }
97
98
  }
98
99
  ]
@@ -103,6 +104,41 @@ interaction gating and form actions — four contexts, one grammar.
103
104
  The `<id>Options` suffix is the same convention as everywhere else in Mosaic
104
105
  (`RuleTypeAbstract::getOptionsDataName()` returns `getID() . 'Options'`).
105
106
 
107
+ **The comparator lives inside the subject's options**, beside `settings`, not beside
108
+ `subject`. The validator reads it as `subject.<type>Options.comparator`
109
+ (`ValidatorEvaluator.php`) and so does the evaluator (`RuleTypeAbstract.php`:
110
+ `$subjectData->{$subjectType . 'Options'}->comparator`). Put it one level up and the
111
+ evaluator finds no comparator and calls
112
+ `SubjectMeta::getLocalOrGlobalComparatorMetaByID(null)`, which is a PHP fatal
113
+ (`Undefined property $comparator`) that takes the WHOLE page down, not just the
114
+ element. Reported from a client build on 1.0.10 and confirmed in the source; the post
115
+ loop filters in `references/dynamic-content.md` use the same nesting.
116
+
117
+ "Show only when a field has a value", as it ran on a client site on 1.0.10:
118
+
119
+ ```json
120
+ {"uuid": "<uuid>", "type": "serverCondition", "serverConditionOptions": {"subject": {
121
+ "type": "variable", "variableOptions": {
122
+ "settings": {"dynamicCode": "@VAR('post/meta_x')"},
123
+ "comparator": {"type": "text", "textOptions": {
124
+ "operator": "regexp", "settings": {"value": "/./"}}}}}}}
125
+ ```
126
+
127
+ - **`dynamicCode` is a plain string.** The `variable` subject hands its setting
128
+ straight to the evaluator; `{"v": "@VAR(…)"}` there reaches
129
+ `LocalContext::evaluateDynamicCodeToStringOrArray()` as a `stdClass` and is a
130
+ page-wide fatal. (A post loop FILTER value is the opposite: it is `{"v": …}`.)
131
+ - **`regexp` needs its delimiters here.** `ServerConditionComparatorText` passes the
132
+ value to `preg_match()` as the whole pattern, so it is `/./`, not `.`. A pattern
133
+ without delimiters makes `preg_match` return false, which is read as "no match":
134
+ the element disappears on every page and nothing is reported. A post loop filter's
135
+ `regexp` is the opposite again - it becomes SQL `REGEXP` and takes NO delimiters
136
+ (`FilterSubjectPostTypes.php`).
137
+ - Units in one group AND: `regexp /./` plus `not-contains` on the same variable is
138
+ "has a value and is not X".
139
+ - Inside a `wpPostType` loop, `@VAR('<loopNamespace>/meta_x')` evaluates per item, so
140
+ each row shows or hides on its own field.
141
+
106
142
  **An empty or absent `conditions` matches.** `matchConditions()` returns `true` when
107
143
  the evaluation yields null, so a template with no conditions always applies and an
108
144
  element with none always renders. Conditions subtract, they never add.
@@ -134,9 +170,9 @@ can be an `@` expression rather than a literal — see `references/dynamic-conte
134
170
  ## Verification status
135
171
 
136
172
  The routing rules and the condition grammar above are read from source and from the
137
- live metas endpoints. **Committing a condition has not been driven end to end** — the
138
- property sweep's naive string probe on `conditions` returns HTTP 500, which is
139
- consistent with the nested shape being required but is not proof the shape above is
140
- accepted. Build one, then read the row back and diff it against what you sent; on this
141
- data model that diff is the only error message you get
142
- (`references/interactions.md` explains why).
173
+ live metas endpoints. Element conditions (`node.data.conditions`) have been driven end
174
+ to end on a client build on 1.0.10 with the shape above - the `variable` subject, the
175
+ `text` comparator's `regexp` and `not-contains`, inside and outside a post loop. Other
176
+ subjects are read from source only; build one, then read the row back and diff it
177
+ against what you sent. A wrong shape is either silently dropped or a fatal on render,
178
+ so load the page after every condition you commit.
@@ -492,17 +492,25 @@ def theme_records(spec, doc, surface):
492
492
 
493
493
  classes = theme.get("variants") or theme.get("elementClasses") or {}
494
494
  if classes:
495
- by_name = {}
496
- for row in load_csv("variants.csv"):
497
- by_name.setdefault(row["name"], []).append(row)
495
+ catalog = load_csv("variants.csv")
498
496
  recs = []
499
497
  for name, states in classes.items():
500
- matches = by_name.get(name) or []
498
+ # a catalog id, a class_name (m-loop-item) or a name. Names repeat -
499
+ # `Item` is the accordion, loop AND list item, all three with a parent -
500
+ # so a name only resolves when it is the sole row, or the sole
501
+ # parent-less one, carrying it
502
+ matches = ([m for m in catalog if m["id"] == name]
503
+ or [m for m in catalog if m["class_name"] == name]
504
+ or [m for m in catalog if m["name"] == name])
501
505
  if not matches:
502
- sys.exit("no variant named %r; see data/variants.csv" % name)
506
+ sys.exit("no variant %r; see data/variants.csv" % name)
507
+ roots = [m for m in matches if not m["parent"]]
508
+ if len(matches) > 1 and len(roots) != 1:
509
+ sys.exit("variant name %r is ambiguous; use one of: %s" % (
510
+ name, ", ".join(m["class_name"] for m in matches)))
503
511
  # a fresh UUID here is accepted and then silently dropped, so the meta ID
504
- # is the only thing that works - take the top-level (parent-less) one
505
- top = next((m for m in matches if not m["parent"]), matches[0])
512
+ # is the only thing that works
513
+ top = roots[0] if len(matches) > 1 else matches[0]
506
514
  recs.append({"newRevisionRecord": {
507
515
  "ID": top["id"], "parentType": "", "parentID": "", "ordering": "a0",
508
516
  "status": "publish", "revision": "", "version": "",
@@ -262,7 +262,11 @@ def apply_overrides(client, instance, key, uses, comps, envelope="templateDocume
262
262
  `strpos($type, 'component-instance')`, and strpos returns 0 for a needle at
263
263
  offset 0, so the condition is false for every instance there will ever be.
264
264
  """
265
- wanted = [u for u in uses if u.get("overrides")]
265
+ # EVERY instance, not only the ones with overrides: this pass is also what gives
266
+ # each instance's inner nodes their own ids (below). Filtering on overrides left
267
+ # an instance without any as a verbatim copy of the definition's ids - a client
268
+ # build put the same component in a footer and a page body and got every id twice.
269
+ wanted = list(uses)
266
270
  if not wanted:
267
271
  return 0
268
272
  doc = unwrap(client.get(instance), envelope)