/entries`) | Compositions API (`POST /v1/projects//compositions`) |
+|---|---|---|
+| Body envelope | `{ "entry": { … } }` | The fields directly, no `entry` wrapper |
+| `ui` | String `"zlib:"` you compress yourself | Uncompressed node **object**. A `zlib:` string fails validation |
+| Where `ui` sits | Top-level entry field, sibling of `static_value`. **Never inside `static_value`** | Same |
+| `data_sources` | JSON-encoded **string**: `"[]"`, `"[{\"uid\":\"template\",…}]"` | Plain **array**: `[]`, `[{ "uid": "template", … }]`. A string answers `400` |
+| `resolvedReferences` key (template data source) | `"template"`. The SDK ignores the key's content, but write `"template"`: it is what Studio writes. Never `"root."` | Same |
+| `resolvedReferences` key (`contentstack` pinned data source) | The pinned **entry uid**: the SDK looks it up by that key | Same |
+| `static_value.number[].value` | Must match the compositions CT's `static_value.number.value` field type. The schema [`provision-studio-project`](provision-studio-project.md) ships makes it **text**, so write `"49"`. A CT whose field is `number` (a measured `422 … is not number`) needs `49`. Read the CT before the first write | Same: the service forwards to the CMA |
+| `static_value.choice[].value` | List: `["h1"]` | Same |
+| `static_value.object` / `.array` values | JSON-encoded string | Same |
+
+One data source, both forms:
+
+```jsonc
+// raw CMA
+"data_sources": "[{\"uid\":\"template\",\"data\":null,\"resolvedReferences\":{\"template\":[\"products\"]}}]"
+// Compositions API
+"data_sources": [{ "uid": "template", "data": null, "resolvedReferences": { "template": ["products"] } }]
+```
+
After write, publish the entry. Studio queries by `composable_uid` + published status. An unpublished entry won't appear in the project's Compositions list or load in the canvas.
**A section write is not finished until `ui_preview` is set.** It fails the way publish state does (silently, with a correct-looking `200` and a page that renders), so it is invisible until an author opens the palette and finds unlabeled gray tiles. Set it in the same pass as the create, per [`author-composition-thumbnails`](author-composition-thumbnails.md): screenshot or generate the image, `POST /v3/assets`, then `PUT { entry: { ui_preview: } }`. Batching it to "later" is how a whole project ends up with none. Measured: 254 API-authored Sections in one project, zero thumbnails, while the UI-authored Sections beside them all had one.
@@ -60,7 +87,7 @@ A published entry returns an array with one object per environment/locale. `[]`
| Composition | `url` | `url_source` | Notes |
|---|---|---|---|
| **Connected** template, CT is a page type (`is_page: true` + CT `url_pattern`) | `//{{entry.title}}` | `content_type_url_pattern` | **The default for the API path.** The CT pattern is the derivation, so Studio re-derives correctly on every editor load. Pair with CT `url_prefix: "//"`. See [`build-connected-template` § Single-entry vs multi-entry URL pattern rule](build-connected-template.md#single-entry-vs-multi-entry-url-pattern-rule) |
-| **Connected** template, CT **has** a `url_pattern` (entries get a real `url`) | `//{{entry.title}}` | `content_type_url_pattern` | Resolution keys on **`entry.url`**: the SDK finds the entry whose stored `url` equals the request path, so write the template `url` in the shape of a real entry's `url` (read one first; a CT `url_prefix` the stored `url` lacks misses with `PREVIEW_ENTRY_NOT_FOUND`). The composition `url` is NOT variable-substituted under this source (see below). Requires every entry to carry a populated `url` |
+| **Connected** template, CT **has** a `url_pattern` (entries get a real `url`) | `//{{entry.title}}` | `content_type_url_pattern` | Resolution keys on **`entry.url`**: the SDK finds the entry whose stored `url` equals the request path, so write the template `url` in the shape of a real entry's `url` (read one first; a CT `url_prefix` the stored `url` lacks misses with `URL_MATCHED_NO_ENTRY`). The composition `url` is NOT variable-substituted under this source (see below). Requires every entry to carry a populated `url` |
| **Connected** template, **singleton** CT with no `url_pattern` (homepage, pricing, a one-entry rewards page) | `/rewards` (literal) | `content_type_url_pattern`, **but only if you populate `url` on the entry by hand** | The source's real requirement is a non-empty `url` field on at least one entry, not a CT `url_pattern`. A singleton has no pattern to generate one, so set `url: "/rewards"` on the entry yourself. Skip that and the editor fails with `MISSING_CT_PATTERN_URL_FIELD`, shown as "No entry has a URL field populated yet", while SSR renders the page fine |
| **Connected** template whose URL derives from a field **other than** `url` (`{{entry.slug}}`, `{{entry.author.slug}}`) | `/blog/{{entry.slug}}` | `user_specified_pattern` | This is the only source that extracts and matches on pattern variables. `content_type_url_pattern` would ignore the pattern and look for `url` instead |
| **Freeform** template, identity URL | `//` | `default_url_pattern` | The Studio-generated default. Use this unless the path was deliberately hand-chosen |
diff --git a/skills/studio/author-composition-nested-repeaters.md b/skills/studio/author-composition-nested-repeaters.md
index 0daa417..6391b98 100644
--- a/skills/studio/author-composition-nested-repeaters.md
+++ b/skills/studio/author-composition-nested-repeaters.md
@@ -70,8 +70,8 @@ Both Repeaters carry `props.children` pointing at their own slot. Without it the
| Source-type combo | Extra prerequisites |
|---|---|
| group-multiple (e.g. `footer_columns`) containing group-multiple (`column.links`) | (nothing extra), inline values both ways |
-| group-multiple containing multi-reference (e.g. column.related_entries) | Add `data_sources.resolvedReferences` for the nested reference path (e.g. `"root.footer_columns.0.related_entries": ["footer_columns.related_entries"]`). Add Condition Block as immediate child of `R-inner`. |
-| multi-reference containing group-multiple (e.g. category.products[].images) | `resolvedReferences` for the outer reference (`root.products`). Condition Block as child of `R-outer`. Inner iteration is over the (inline) `images` group-multiple, no extra prerequisites for `R-inner`. |
+| group-multiple containing multi-reference (e.g. column.related_entries) | Add `data_sources.resolvedReferences` for the nested reference path (e.g. `"template": ["footer_columns.related_entries"]`). Add Condition Block as immediate child of `R-inner`. |
+| multi-reference containing group-multiple (e.g. category.products[].images) | `resolvedReferences` for the outer reference (`"template": ["products"]`). Condition Block as child of `R-outer`. Inner iteration is over the (inline) `images` group-multiple, no extra prerequisites for `R-inner`. |
| multi-reference containing multi-reference | `resolvedReferences` entries for BOTH the outer reference AND each inner reference path, inner path is `.`, with **no** content-type uid between them (§ [Where a reference path must terminate](author-composition-repeater-recipes.md#reference-path-termination)). Condition Block in both Repeaters. |
| **Modular Block with a reference inside a block** (e.g. `blocks[].feature_block.related_products`) | `resolvedReferences` path must include the **block uid** segment, `"blocks.feature_block.related_products"`, NOT `"blocks.related_products"`. Condition Block per block uid on the Repeater. See § Modular Block to reference: the block-uid segment below. |
@@ -89,7 +89,8 @@ Omitting the block uid fails silently, for the reason given in § [Where a refer
```jsonc
// Template composition — MB field `blocks`, block uid `feature_block`,
-// reference field `related_products` inside that block.
+// reference field `related_products` inside that block. Raw CMA form (a JSON string);
+// the Compositions API takes the array: author-composition-entry § Wire shapes.
"data_sources": "[{\"uid\":\"template\",\"data\":null,\"resolvedReferences\":{\"template\":[\"blocks.feature_block.related_products\"]}}]"
```
diff --git a/skills/studio/author-composition-pitfalls.md b/skills/studio/author-composition-pitfalls.md
index cbd3644..1ef6c82 100644
--- a/skills/studio/author-composition-pitfalls.md
+++ b/skills/studio/author-composition-pitfalls.md
@@ -56,7 +56,7 @@ Three lines, catches the entire class. Run it in the same pass as the `static_va
| Rebuilding a template's root slot from a fresh node list | Sibling nodes that sat beside the main one (a closing CTA band, a comparison table, a content list) are dropped. The page still renders, so nothing flags it | Rebuild from the newest version whose root slot holds no `section-composition` node, and carry every sibling across as its own Section |
| Deriving a composition title by stripping a suffix from a scope name | Two scopes collapse to one title. Titles are unique per project, so the second create returns `409` and that Section is missing from the template | Keep the title 1:1 with the scope it is rooted at |
| Creating a Section and placing it in the same pass without publishing | The Section resolves to nothing at delivery and the page renders `Component with type '' is not registered` | Publish every Section before the Template that references it. See [`complete-the-build`](complete-the-build.md) |
-| Missing `data_sources.resolvedReferences` | Repeater renders 0 items, reference stubs never materialise | Add the resolution map entry: `{ uid:"template", resolvedReferences: { "root.": [""] } }` |
+| Missing `data_sources.resolvedReferences` | Repeater renders 0 items, reference stubs never materialise | Add the resolution map entry: `{ uid:"template", resolvedReferences: { "template": [""] } }`, in the per-API form from [`author-composition-entry`](author-composition-entry.md#wire-shapes) § Wire shapes |
| Authoring from the registration without reading the component source | The registration lists prop names and options but not how they are consumed. A prop that overrides another, or is compared by equality, discards correct values with no error | Open the component file for every component you author. See § Read the component source before authoring |
| A list-wrapped `choice` hitting a strict equality check in component code | `['auto'] !== 'auto'` is true, so an override branch fires and the props it guards are dropped, `justify` silently stops working | Leave equality-compared props unwritten so the component's string default applies. Report the array-handling bug to the library owner |
| Composing atomically without reading how the composite derives its values | A composite computes child values from its own scheme: `isDark ? 'medium' : 'light'`. Hand-authoring the same tree with the atom defaults gets colors wrong in a way that looks plausible | Treat the composite's derivation logic as the specification. See § Read the component source before authoring point 3 |
@@ -66,7 +66,7 @@ Three lines, catches the entire class. Run it in the same pass as the `static_va
| Assuming a card primitive's own `justify-between` will distribute your nodes | The padded inner element of a card component is often `justify: normal`, and it is inside the component where no prop reaches it. Children pack at the top however they are nested | Put one stack inside with an explicit height (card height minus the component's own padding) and set `justify` on that |
| Setting `justify: between` on a container with no height | Distribution needs free space. A card whose height was removed to avoid clipping packs its children at the top, so CTAs sit at ragged heights instead of pinned to the bottom | Give the container the comp's height. Note that `height: 100%` may not chain through the SDK's per-node wrapper divs |
| Setting `justify` on a row that has no width | `justify` distributes free space. A shrink-wrapped row has none, so the value applies and nothing moves. A parent stack with `align: start` shrink-wraps its children | Give the row a width and the parent `align: stretch`. Measure the rendered box, not the stored prop |
-| A numeric value written into the `number` bucket as a string | The bucket is strictly typed: the CMA rejects the whole entry with `422 static_value.number.N.value is not number`. Easy to hit when defaults are parsed out of source, where `1` arrives as `"1"` | Coerce to a real int/float before writing. Only `choice` wraps in a list. `number` stays a bare number |
+| A `number` bucket value whose JSON type doesn't match the compositions CT's `static_value.number.value` field | The CMA rejects the whole entry: `422 static_value.number.N.value is not number` on a CT that types the field `number`, `is not text` on one that types it text (the schema [`provision-studio-project`](provision-studio-project.md) ships) | Read the CT once and write the matching type: `49` for `number`, `"49"` for text. Only `choice` wraps in a list. See [`author-composition-entry`](author-composition-entry.md#wire-shapes) § Wire shapes |
| Using a decorative shape primitive as an in-flow rule or divider | Its wrapper is `position:absolute; inset:0`, so it takes no space and pins to the section, not the card. It measures correct and renders in the wrong place: four stacked indicators read as one stray white line over the heading | Read the component first. Verify the rendered box sits inside its intended parent, not just that its size and color match |
| Reporting a value as impossible without checking for a free-form primitive | A `Deviations` row that says impossible stops anyone looking again, so an expressible value stays unbuilt indefinitely | Search the registry for shape/box primitives with raw CSS `color` / `width` / `height` first |
| `choice` value stored as a bare string instead of a list | **Renders correctly**: the canvas is right and every visual check passes, but the Settings panel matches it against the registered `options`, finds nothing, and shows `Select…`. The author re-picks a value that was already set | `"value": ["h1"]`. Only `choice` wraps. Boolean/text/number stay scalar. See § Node anatomy point 0a |
@@ -77,7 +77,8 @@ Three lines, catches the entire class. Run it in the same pass as the `static_va
| Design props left unwritten on an API-authored section | Section renders with the library's defaults, not the comp: a stack defaults to `column`, so side-by-side CTAs stack. No error | Author design props explicitly, skeleton or not. See § Skeleton first, bind later |
| Card prop bindings use `template` type at `.0.` (fixed `.0.` index) | EVERY iteration renders item 0, all cards identical (the worst kind: looks "correct" with a single-item list, fails as soon as the list grows) | Use `repeater` type with `repeaterUID`, path relative to the iteration item, no index |
| Repeater node missing `metadata.mode: "preview"` | Canvas shows 1 placeholder iteration. Production still renders N | Set `metadata.mode: "preview"` on the Repeater node |
-| Missing Condition Block child of Repeater (reference / modular-block iteration) | Iteration silently fails to resolve child bindings | Insert a Condition Block per allowed type (CT for refs, block UID for modular blocks) |
+| Missing Condition Block under the Repeater (reference / modular-block iteration) | Iteration silently fails to resolve child bindings | Insert a Condition Block per allowed type (CT for refs, block UID for modular blocks) inside the item Box. See the [canonical list recipe](author-composition-repeater-recipes.md#canonical-list-recipe) |
+| A Condition Block, Section or Section Slot as the Repeater's direct child | The list renders, but Visual Editor gets no item tags: no move, delete or add | Make an item Box the Repeater's only child. The validator's "items won't render" warning on that Box is expected |
| Writing `url` on a template composition but omitting `url_metadata` ("minimal envelope") | **Nothing fails.** The CMA returns 201, the live route returns 200, delivery resolves off the `url` pattern alone, so the omission survives review. But Studio has no derivation to read, treats the composition as `legacy_url`, and the editor's Edit-URL panel shows the pattern as an opaque hand-typed string instead of "derived from the connected CT". The CT link is lost the first time an author edits the URL, and `legacy_url` matching (entry `url` field, leading slash, every entry) is stricter than what the pattern was written for | Always write the pair. For Connected on a page-type CT, use `url_source: "content_type_url_pattern"`. For a Freeform identity URL, use `default_url_pattern`. For a Freeform hand-chosen path, use `user_specified_pattern`. `url_queries` is a JSON string. See § [`url` + `url_metadata`: a matched pair](author-composition-entry.md#url-metadata-matched-pair) |
| Compositions CT has no `url_metadata` group (or is missing the `url_source` / `url_queries` sub-fields) | CMA **silently drops** the object: 201 returned, entry reads back with no `url_metadata`, same `legacy_url` outcome as omitting it, with no error to trace | Verify the CT models `url_metadata` as a group with both text sub-fields (see `provision-studio-project`). Read the entry back after the first write and assert `url_metadata.url_source` is what you sent |
| Setting `url_source: "user_specified_pattern"` on a **Connected** template via the API | Reverts: `url_queries` is only generated by the UI's Edit-URL then Save flow, and Studio re-derives from the CT pattern on editor load | Make the CT a page type (`is_page: true` + CT `url_pattern`), then use `content_type_url_pattern` with `{{entry.x}}` in the composition `url`. `user_specified_pattern` on the API path is for Freeform only |
@@ -96,17 +97,17 @@ Three lines, catches the entire class. Run it in the same pass as the `static_va
| Placing Condition Block's discriminator on the node as `condition: {...}` instead of `metadata: { condition: {...} }` | Discriminator ignored. Iteration item's type can't be narrowed. Bindings inside fail to resolve | Put `condition` inside `metadata`. Same pattern as Repeater's `metadata.mode`. |
| `metadata.condition` without `conditionBinding` | Block renders nothing, no error. Design mode still shows it | Add it (`author-composition-repeater-recipes` step 4). Verify in preview. |
| Adding `resolvedReferences` for a group-multiple iteration | No harm (the SDK ignores it for non-references), but creates noise + makes the composition look more complex than it is | Skip `resolvedReferences` for group-multiple, multi-file, and scalar-list iteration. Only references need it. |
-| Adding a Condition Block for group-multiple iteration | The block has no discriminator to apply. Runtime behavior may be inconsistent | Skip the Condition Block for group-multiple. Place the iteration child directly in the Repeater's slot. |
+| Adding a Condition Block for group-multiple iteration | The block has no discriminator to apply. Runtime behavior may be inconsistent | Skip the Condition Block for group-multiple. Place the iteration child directly in the item Box. |
| Using `uid` instead of `queryUID` in a `contentstack_queries` binding value | Pinned-query result never resolves | Use `queryUID: ""`. |
| Inner Repeater's `items` binding uses `type: "template"` with a nested-`.0.` path (e.g. `{ footer_columns: { 0: { links: {} } } }`) | Every outer iteration's inner-repeater reads column-0's links, all inner lists identical. The fixed-index (`.0.`) trap, one level up. | Inner Repeater's `items` MUST use `type: "repeater"` with `repeaterUID: ""` and path `{ : {} }`. Then inner card bindings use the inner Repeater's UID. |
| Compositions CT models `linked_schemas` as a reference field instead of group-multiple | CMA silently drops the `{content_type_uid, selected_field}` object on every `PUT` (`linked_schemas` reads back as `[]`), editor canvas shows blank repeaters on placed sections even though runtime works correctly | Model `linked_schemas` as a group-multiple on the compositions CT (see `provision-studio-project`). As a temporary runtime-only fix, set `sectionBindingOverride.selectedField` on each `section-composition` node, but the editor canvas won't render correctly until `linked_schemas` is also fixed. |
-| A section iterates `selectedField` and its top-level Repeater binds `{ path: { : {} } }` instead of scope-root `{ path: {} }` | Repeater renders nothing: `dataSources.template` IS the array (re-scoped via selectedField). `template.` is undefined | Use scope-root `{ path: {} }` for the top-level Repeater inside a `selectedField`-scoped section. Leaf bindings inside still use `repeater` type with `repeaterUID`. |
+| A section scoped to the list field (`selectedField: ""`) | With a Repeater on `{ : {} }` it renders nothing (`template` IS the array). With scope-root `{ path: {} }` it renders, but Visual Editor gets no list or item tags | Scope the section to the whole entry and bind the Repeater to `{ : {} }`. See the [canonical list recipe](author-composition-repeater-recipes.md#canonical-list-recipe) |
| Setting `selectedField` on a section that reads multiple page fields (Header reading brand + nav_links + signin_label. Hero reading the `hero` group) | Section loses access to the fields outside the scoped one, they resolve to undefined | Leave `selectedField` unset for multi-field sections. `template` becomes the whole page entry. Original `template.` paths work verbatim. |
| Authoring a section by API and expecting its own canvas to confirm scope | Section canvas has no data. One empty placeholder is normal | Verify via SSR cold-load: `await sdk.fetchCompositionData({ url })` then dump `spec.data.section_scoped_data[]`. That's the runtime truth. |
| API-placing a section in a template's `ui` without populating the template's `linked_sections` field | **"Template Did Not Load"** in the Studio editor, BUT the deployed site renders the template correctly via SSR: the editor needs the `?include[]=linked_sections` resolution to build `spec.sectionCompositions`. SSR walks the section-composition nodes directly so it works either way | When writing `section-composition` nodes into a template's `ui`, also populate the template entry's `linked_sections` field with `[{ uid: "", _content_type_uid: "" }, …]`, one entry per distinct placed section (dedupe: a section placed multiple times needs only one reference). Publish the template entry. |
| Passing the compressed `ui` inline via a shell variable in `curl -d "..."` | Blank canvas, no error, spec present, `hasSpec: true`, but every slot resolves empty because shell interpolation mutated `"children"` into `"c1ildren"` throughout the tree | Write payload to a temp file, POST with `-d @file`. Assert `raw.count('"children"') >= ` before compressing. See § Never pass the compressed `ui` inline above. |
| Using Python `urllib`/`requests` for CMA calls against a custom / private data center | `SSLCertVerificationError: unable to get local issuer certificate` when that DC's chain isn't in Python's default CA store. `curl` reads the system keychain and works | Use `curl` for the HTTP call. Keep Python for payload build + assertions only. If Python HTTP is unavoidable, point it at the trusted CA bundle for that host (`ssl.create_default_context(cafile=...)` or `REQUESTS_CA_BUNDLE`). Never disable certificate or hostname verification; if the chain can't be verified, stop and report it |
-| `section-slot` node written without `slots: { "": [] }` (empty-array entry keyed by the node's own uid) | The template has no slot uid to fill under: canvas renders the wrapper with no slot placeholder. SSR resolves the slot as absent | Every `section-slot` node needs `slots: { "": [] }`, the one legitimate empty-array exception to the preflight rule. See § `section-slot` node shape |
+| `section-slot` node written with `slots: { "": [] }` | The Compositions API rejects it: `422 Empty slots crash the renderer.` The raw CMA accepts it, and the renderer can then throw on the empty array | Write `"slots": {}`. The node's own uid is the fill key on the template side, it needs no entry here. See [`author-composition-sections`](author-composition-sections.md) § `section-slot` node shape |
| Pattern B fill section's leaf bindings use `type: "repeater"` with the wrapper's `repeaterUID` | Bindings resolve to `undefined`: the wrapper's SDK scoping already unwraps the iteration item into the fill section's `template`. The wrapper's repeater scope isn't visible inside the fill section | Fill section's leaf bindings use `type: "template"` with paths relative to the reference target CT / block schema. Only the WRAPPER section uses repeater-scope bindings inside its own tree |
| Pattern B fill written as `metadata.sectionSlotCompositions` on the placed wrapper | The SDK never reads that key, so each iteration's slot renders blank | Put the fill in the wrapper's `slots`: `slots.` holds a `section-composition` node with `metadata.compositionUID` = the fill section's entry uid. Add the fill section entry to the template's `linked_sections` |
| Exposed-prop declaration placed on the individual node's `metadata.sectionExposedProps` or on the composition entry, instead of `ui.metadata.sectionExposedProps` at the ui ROOT | Template's right panel doesn't render the field. Overrides never get read | Place `sectionExposedProps` on the section's `ui` ROOT node's `metadata` (the top-level `page` node). It's an array of `{nodeUid, propKey, uid, displayName, propType, bindingAtExposeTime}`, one entry per exposure. See § Recipe: Exposed section props |
diff --git a/skills/studio/author-composition-practice.md b/skills/studio/author-composition-practice.md
index ac35ce9..20907f7 100644
--- a/skills/studio/author-composition-practice.md
+++ b/skills/studio/author-composition-practice.md
@@ -50,24 +50,31 @@ For content props, prefer the comp's own copy as the `static_value` over relying
[ { "key": "-items" } ]
```
-The wrapper holding the repeater carries `metadata.repeaterWrapper: true`, and its `direction` / `gap` / `wrap` props produce the row or grid the iterations flow into. Verified against a production section authored this way with `linked_schemas: []` and no data sources at all.
+The wrapper holding the repeater carries `metadata.repeaterWrapper: true`, and its `direction` / `gap` / `wrap` props produce the row or grid the iterations flow into. The repeater's one child is an item Box ([canonical list recipe](author-composition-repeater-recipes.md#canonical-list-recipe)). Verified against a production section authored this way with `linked_schemas: []` and no data sources at all.
**A list whose items have different shapes needs one Condition Block per shape, all of them, in the skeleton.** A carousel showing stat / image / quote cards is not three lists and not one card repeated. It is one Repeater with three branches. Build every branch. Stopping after the first leaves two thirds of the design unbuilt while the canvas looks plausible, and the omission is invisible once the sheet is written.
```jsonc
-// repeater.slots. holds ONE condition-block per item type
+// the item Box under the repeater holds ONE condition-block per item type
+// (canonical list recipe: author-composition-repeater-recipes)
{
+ "uid": "",
"type": "condition-block",
+ "attrs": {},
"metadata": {
+ "title": "Condition — ",
"slotNames": { "": "Contents" },
"condition": {
"type": "modular_block", "operator": "eq", "value": "",
"conditionBinding": { "type": "repeater",
"value": { "repeaterUID": "", "path": { "": {} } } },
- "dataBinding": { /* same shape */ }
+ "dataBinding": { "type": "repeater",
+ "value": { "repeaterUID": "", "path": { "": {} } } }
}
},
- "props": { "children": { "type": "slot", "slot": "" } }
+ "props": { "children": { "type": "slot", "slot": "" } },
+ "slots": { "": [ /* this branch's card */ ] },
+ "styles": {}
}
```
diff --git a/skills/studio/author-composition-repeater-recipes.md b/skills/studio/author-composition-repeater-recipes.md
index 2180d28..20121d5 100644
--- a/skills/studio/author-composition-repeater-recipes.md
+++ b/skills/studio/author-composition-repeater-recipes.md
@@ -8,6 +8,117 @@ Worked recipes for repeating a card over a multi-reference field and over a GROU
Part of the API-authoring set. Start at [`author-composition-via-api`](author-composition-via-api.md), which routes to this one.
+
+
+## The canonical list recipe {#canonical-list-recipe}
+
+Every list Section uses this one shape. Other files link here instead of restating it.
+
+1. **Scope the Section to the whole entry.** `linked_schemas: [{ "content_type_uid": "" }]`, no `selected_field`. A Section scoped to the list field renders, but Visual Editor finds no list or item tags ([`make-visual-editor-editable`](make-visual-editor-editable.md) § 2).
+2. **A list Box holds the Repeater.** It carries `metadata.repeaterWrapper: true` (the list tag `` lands on it) and the list layout: flex or grid with `gap` ([`build-repeating-section`](build-repeating-section.md) § The Repeater's wrapper element).
+3. **The Repeater binds to the field**: `items` = `template` binding on `{ "": {} }`, `metadata.mode: "preview"`, `metadata.repeaterBindingFieldType` per [`author-composition-bindings`](author-composition-bindings.md) § Binding a Repeater.
+4. **An item Box is the Repeater's only child.** The item tag `.` lands on it. Without it, a Condition Block holding a Section or Section Slot drops every item tag.
+5. **Condition Blocks go inside the item Box**, one per content type (references) or block uid (modular blocks), each with `operator` and `conditionBinding`. Group-multiple, multi-file and scalar lists skip this step: the card goes straight into the item Box.
+6. **Card bindings** use `type: "repeater"` with the Repeater's `repeaterUID`, paths relative to the item.
+
+The section's full `ui` tree for a multi-reference `products` field, every node with all seven keys:
+
+```json
+{
+ "uid": "ROOT",
+ "type": "page",
+ "attrs": {},
+ "metadata": { "title": "Product Grid", "sectionExposedProps": [] },
+ "props": { "children": { "type": "slot", "slot": "ROOT-slot" } },
+ "slots": {
+ "ROOT-slot": [
+ {
+ "uid": "LIST",
+ "type": "box",
+ "attrs": {},
+ "metadata": { "title": "Box — Product list", "repeaterWrapper": true },
+ "props": { "children": { "type": "slot", "slot": "LIST-slot" } },
+ "slots": {
+ "LIST-slot": [
+ {
+ "uid": "R1",
+ "type": "repeater",
+ "attrs": {},
+ "metadata": { "title": "Repeater — Products", "mode": "preview", "repeaterBindingFieldType": "reference" },
+ "props": {
+ "items": { "binding": { "type": "template", "value": { "path": { "products": {} } } } },
+ "children": { "type": "slot", "slot": "R1-slot" }
+ },
+ "slots": {
+ "R1-slot": [
+ {
+ "uid": "ITEM",
+ "type": "box",
+ "attrs": {},
+ "metadata": { "title": "Box — Product item" },
+ "props": { "children": { "type": "slot", "slot": "ITEM-slot" } },
+ "slots": {
+ "ITEM-slot": [
+ {
+ "uid": "CB1",
+ "type": "condition-block",
+ "attrs": {},
+ "metadata": {
+ "title": "Condition — product",
+ "condition": {
+ "type": "reference", "operator": "eq", "value": "product",
+ "conditionBinding": { "type": "repeater",
+ "value": { "repeaterUID": "R1", "path": { "_content_type_uid": {} } } }
+ }
+ },
+ "props": { "children": { "type": "slot", "slot": "CB1-slot" } },
+ "slots": {
+ "CB1-slot": [
+ {
+ "uid": "CARD1",
+ "type": "site-product-card",
+ "attrs": {},
+ "metadata": { "title": "Product card" },
+ "props": {
+ "title": { "binding": { "type": "repeater", "value": { "repeaterUID": "R1", "path": { "title": {} } } } },
+ "price": { "binding": { "type": "repeater", "value": { "repeaterUID": "R1", "path": { "price": {} } } } }
+ },
+ "slots": {},
+ "styles": {}
+ }
+ ]
+ },
+ "styles": {}
+ }
+ ]
+ },
+ "styles": {}
+ }
+ ]
+ },
+ "styles": {}
+ }
+ ]
+ },
+ "styles": {
+ "default": {
+ "classes": [""],
+ "responsiveStyles": { "default": { "display": "grid", "gridTemplateColumns": "repeat(3, minmax(0, 1fr))", "gap": "24px" } }
+ }
+ }
+ }
+ ]
+ },
+ "styles": {}
+}
+```
+
+The composition also needs `data_sources` with `resolvedReferences` `{ "template": ["products"] }` (step 1 below). A modular-blocks list swaps the condition to `"type": "modular_block"` with the block uid ([step 4](#4-condition-block-inside-the-item-box)) and `repeaterBindingFieldType` to `"modular_block"`.
+
+**Expected validator warning.** The Compositions API validator warns on the item Box that the Repeater's items won't render. On this shape the warning is expected and wrong: leave the Box in place. It is a warning, not a `422`, and the items render (see [`use-compositions-api`](use-compositions-api.md) § Expected validator warnings).
+
+The recipes below explain each piece.
+
## Recipe: Repeat a card over a multi-reference field
The canonical pattern. Requires FIVE shapes in concert. Missing any one yields a silently-empty repeater or (worse) an all-items-identical render (the `template`-at-`.0.` trap below).
@@ -30,13 +141,7 @@ The reference-resolution map. Tells the SDK "when bindings resolve through this
]
```
-Example: a section (Card Grid) that iterates `related_posts`:
-
-```json
-"data_sources": "[{\"uid\":\"template\",\"data\":null,\"resolvedReferences\":{\"template\":[\"related_posts\"]}}]"
-```
-
-**Wire format quirk:** on the CMA `data_sources` is stored as a **JSON-encoded string** (`"[]"`, `"[{…}]"`), not the parsed array. Serialize before write. Parse after read.
+Shown parsed. On the raw CMA `data_sources` is a **JSON-encoded string**, on the Compositions API a plain array: [`author-composition-entry`](author-composition-entry.md#wire-shapes) § Wire shapes.
**Key semantics: depends on the data source, and getting it wrong fails silently:**
@@ -96,58 +201,59 @@ The Repeater consumes its iteration source through its `items` prop:
`props.children` is **required**: it wires the `R1-slot` slot to the Repeater's built-in `children` prop. Without it the Repeater iterates but renders nothing ([`author-composition-bindings`](author-composition-bindings.md) § Node anatomy, point 4). The `slots` key MUST match `props.children.slot`.
-For a section whose `selectedField` is the reference itself (`build-section`'s scope-aware sections), the path collapses to scope-root `{ "path": {} }`, the section's `dataSources.template` is already the resolved array.
+A Section scoped to the reference itself (`selectedField: "products"`) would need scope-root `{ "path": {} }`, because its `dataSources.template` is already the array. Don't author new Sections that way: it drops the Visual Editor list tags. Use the whole-entry scope of the [canonical list recipe](#canonical-list-recipe).
### 3. Repeater node's `metadata.mode: "preview"`
On the same Repeater node, in `metadata` (parallel to `props`). Per `use-repeater`'s two-mode model: Design Mode (default) renders one placeholder. Preview Mode renders N real iterations. UI authors toggle this in Properties, under Configuration. API authors set the metadata directly. See the Repeater node shape in step 2 above for placement.
-### 4. Condition Block as the immediate child of the Repeater (under `slots`)
+### 4. Condition Block inside the item Box
-Required by Studio's composition schema for reference iteration (single-CT AND multi-CT, `use-condition-block`). Narrows the iteration item to a specific content type before child bindings resolve.
+Required for reference iteration (single-CT AND multi-CT, `use-condition-block`). Narrows the iteration item to a specific content type before child bindings resolve.
-The Condition Block lives under a slot of the Repeater. Its discriminator is in `metadata.condition`:
+The Condition Block sits in the slot of the item Box that is the Repeater's only child ([canonical list recipe](#canonical-list-recipe), step 4). Its discriminator is in `metadata.condition`. The Repeater's `slots`, from the item Box down:
```jsonc
-{
- "type": "repeater",
- "uid": "R1",
- // …items binding / metadata.mode as in step 2…
- "props": {
- "items": { "binding": { /* …as step 2… */ } },
- "children": { "type": "slot", "slot": "" } // ← wire the Repeater's slot
- },
- "slots": {
- "": [
- {
- "type": "condition-block",
- "uid": "CB1",
- "metadata": {
- "condition": {
- "type": "reference", "operator": "eq", "value": "product",
- "conditionBinding": { "type": "repeater",
- "value": { "repeaterUID": "R1", "path": { "_content_type_uid": {} } } }
+"slots": {
+ "R1-slot": [
+ {
+ "type": "box", "uid": "ITEM", "attrs": {}, "metadata": {}, "styles": {},
+ "props": { "children": { "type": "slot", "slot": "ITEM-slot" } }, // ← the item Box
+ "slots": {
+ "ITEM-slot": [
+ {
+ "type": "condition-block",
+ "uid": "CB1", "attrs": {}, "styles": {},
+ "metadata": {
+ "condition": {
+ "type": "reference", "operator": "eq", "value": "product",
+ "conditionBinding": { "type": "repeater",
+ "value": { "repeaterUID": "R1", "path": { "_content_type_uid": {} } } }
+ }
+ },
+ "props": { "children": { "type": "slot", "slot": "CB1-slot" } }, // ← Condition Block needs it too
+ "slots": { "CB1-slot": [ /* card subtree here — step 5 */ ] }
}
- },
- "props": {
- "children": { "type": "slot", "slot": "" } // ← Condition Block needs it too
- },
- "slots": {
- "": [
- /* card subtree here — step 5 */
- ]
- }
+ ]
}
- ]
- }
+ }
+ ]
}
```
Without `conditionBinding` the block silently renders nothing. Design mode hides this: verify in preview.
-For modular-block iteration: `type: "modular_block"`, `value: ""`, `conditionBinding` and `dataBinding` on path `{ "": {} }`.
+For modular-block iteration the condition reads the block uid, and carries a `dataBinding` of the same shape:
+
+```json
+"condition": {
+ "type": "modular_block", "operator": "eq", "value": "",
+ "conditionBinding": { "type": "repeater", "value": { "repeaterUID": "R1", "path": { "": {} } } },
+ "dataBinding": { "type": "repeater", "value": { "repeaterUID": "R1", "path": { "": {} } } }
+}
+```
-Both container nodes carry `props.children` pointing at their own slot ([`author-composition-bindings`](author-composition-bindings.md) § Node anatomy, point 4). The Condition Block also exposes a `children` slot prop. Skip its pointer and the card subtree never renders even though the Repeater iterates correctly.
+All three container nodes (Repeater, item Box, Condition Block) carry `props.children` pointing at their own slot ([`author-composition-bindings`](author-composition-bindings.md) § Node anatomy, point 4). The Condition Block also exposes a `children` slot prop. Skip its pointer and the card subtree never renders even though the Repeater iterates correctly.
### 5. Card prop bindings: type `repeater`, with `repeaterUID`
@@ -198,30 +304,19 @@ Composition entry
├── data_sources: [{ uid:"template",
│ resolvedReferences:{ "template":["products"] } }]
└── ui (zlib-inflated tree)
- └── Section "ProductGrid"
- └── Box (grid layout — register-component-render-contracts § Layout contract)
- └── { type:"repeater", uid:"R1",
- metadata:{ mode:"preview" },
- props:{ items:{ binding:{ type:"template",
- value:{ path:{ products:{} } } } } },
- slots:{ "":[
- { type:"condition-block", uid:"CB1",
- metadata:{ condition:{ …step 4… } },
- slots:{ "":[
- { type:"site-product-card", uid:"CARD1",
- props:{
- title:{ binding:{ type:"repeater",
- value:{ repeaterUID:"R1", path:{ title:{} } } } },
- image:{ binding:{ type:"repeater",
- value:{ repeaterUID:"R1", path:{ images:{ 0:{ url:{} } } } } } },
- price:{ binding:{ type:"repeater",
- value:{ repeaterUID:"R1", path:{ price:{} } } } }
- } }
- ] } }
- ] } }
+ └── Section "ProductGrid" (linked_schemas: [{ content_type_uid:"category" }], whole entry)
+ └── Box "list" (metadata.repeaterWrapper:true, grid + gap)
+ └── Repeater R1 (items: template path {products:{}}, metadata.mode:"preview")
+ └── Box "item"
+ └── Condition Block CB1 (type:"reference", operator:"eq", value:"product",
+ │ conditionBinding: repeater R1 path {_content_type_uid:{}})
+ └── site-product-card CARD1
+ title: repeater R1 path {title:{}}
+ image: repeater R1 path {images:{0:{url:{}}}}
+ price: repeater R1 path {price:{}}
```
-Five shapes: composition `resolvedReferences` + Repeater `props.items.binding` + Repeater `metadata.mode` + Condition Block in slot with `metadata.condition` + repeater-scope card bindings. Any one missing produces an empty render or an all-identical render.
+The node-for-node JSON is in [the canonical list recipe](#canonical-list-recipe). Five shapes: composition `resolvedReferences` + Repeater `props.items.binding` + Repeater `metadata.mode` + Condition Block with `metadata.condition` (`conditionBinding` included) + repeater-scope card bindings. Any one missing produces an empty render or an all-identical render.
## Recipe: Repeat a card over a GROUP-MULTIPLE field
@@ -234,7 +329,7 @@ Scenario: a `category` entry has a `features` field of type `group` + `multiple:
| Shape | Multi-reference | **Group-multiple** |
|---|---|---|
| Composition `data_sources.resolvedReferences` | **Required**: reference stubs need resolving | **NOT needed**: values are inline on the parent entry |
-| Condition Block as Repeater's immediate child | **Required**: references are polymorphic (any allowed CT). The block narrows | **NOT needed**: group-multiple has one shape, not polymorphic |
+| Condition Block inside the item Box | **Required**: references are polymorphic (any allowed CT). The block narrows | **NOT needed**: group-multiple has one shape, not polymorphic |
| Repeater `props.items.binding` | `type: "template"`, path at the multi-ref field | (same) `type: "template"`, path at the group-multiple field |
| Repeater `metadata.mode: "preview"` | Required for canvas iteration | (same) Required |
| Card prop bindings | `type: "repeater"`, `repeaterUID`, no index | (same) `type: "repeater"`, `repeaterUID`, no index |
@@ -248,27 +343,17 @@ Composition entry
├── composable_uid: "<15-char>" (= CMA entry uid)
├── data_sources: [/* no resolvedReferences entry needed */]
└── ui (zlib-inflated tree)
- └── Section "FeatureList"
- └── Box (flex / grid layout — register-component-render-contracts § Layout contract)
- └── { type:"repeater", uid:"R2",
- metadata:{ mode:"preview" },
- props:{ items:{ binding:{ type:"template",
- value:{ path:{ features:{} } } } } },
- slots:{ "":[
- /* card goes here directly — no Condition Block wrapper */
- { type:"site-feature-card", uid:"FC1",
- props:{
- headline:{ binding:{ type:"repeater",
- value:{ repeaterUID:"R2", path:{ headline:{} } } } },
- body:{ binding:{ type:"repeater",
- value:{ repeaterUID:"R2", path:{ body:{} } } } },
- icon:{ binding:{ type:"repeater",
- value:{ repeaterUID:"R2", path:{ icon:{ url:{} } } } } }
- } }
- ] } }
+ └── Section "FeatureList" (whole-entry scope)
+ └── Box "list" (metadata.repeaterWrapper:true, flex / grid + gap)
+ └── Repeater R2 (items: template path {features:{}}, metadata.mode:"preview")
+ └── Box "item"
+ └── site-feature-card FC1 (no Condition Block)
+ headline: repeater R2 path {headline:{}}
+ body: repeater R2 path {body:{}}
+ icon: repeater R2 path {icon:{url:{}}}
```
-The card sits **directly inside the Repeater's slot**, no Condition Block in between, because the iteration item has one shape (the group's sub-fields). Each card prop reads from the iteration item via `repeaterUID:"R2"` + the field path on the item.
+The card sits **directly inside the item Box**, no Condition Block, because the iteration item has one shape (the group's sub-fields). Each card prop reads from the iteration item via `repeaterUID:"R2"` + the field path on the item.
### Decision rule: which recipe to use
diff --git a/skills/studio/author-composition-section-slot-fill.md b/skills/studio/author-composition-section-slot-fill.md
index 998e79f..961c11a 100644
--- a/skills/studio/author-composition-section-slot-fill.md
+++ b/skills/studio/author-composition-section-slot-fill.md
@@ -27,66 +27,98 @@ Sibling to the multi-reference recipe in [`author-composition-repeater-recipes`]
## Wrapper section: Repeater + CB + section-slot
-```jsonc
-// ui tree of the wrapper section
+```json
{
- "type": "repeater", "uid": "R1",
- "metadata": { "mode": "preview" },
+ "uid": "R1",
+ "type": "repeater",
+ "attrs": {},
+ "metadata": { "title": "Repeater — Authors", "mode": "preview" },
"props": {
"items": { "binding": { "type": "template", "value": { "path": {} } } },
- "children": { "type": "slot", "slot": "" } // required, see repeater-recipes step 2
+ "children": { "type": "slot", "slot": "R1-slot" }
},
- "slots": { "": [
- { "type": "condition-block", "uid": "CB1",
- "metadata": { "condition": { "type": "reference", "operator": "eq", "value": "author",
- "conditionBinding": {…} } }, // required, see repeater-recipes step 4
- "props": { "children": { "type": "slot", "slot": "" } },
- "slots": { "": [
- { "type": "section-slot", "uid": "SS1",
- "slots": { "SS1": [] } }
- ] } }
- ] } }
+ "slots": {
+ "R1-slot": [
+ {
+ "uid": "CB1",
+ "type": "condition-block",
+ "attrs": {},
+ "metadata": {
+ "title": "Condition — author",
+ "condition": {
+ "type": "reference", "operator": "eq", "value": "author",
+ "conditionBinding": { "type": "repeater",
+ "value": { "repeaterUID": "R1", "path": { "_content_type_uid": {} } } }
+ }
+ },
+ "props": { "children": { "type": "slot", "slot": "CB1-slot" } },
+ "slots": {
+ "CB1-slot": [
+ { "uid": "SS1", "type": "section-slot", "attrs": {}, "metadata": { "title": "Section Slot — Author" },
+ "props": {}, "slots": {}, "styles": {} }
+ ]
+ },
+ "styles": {}
+ }
+ ]
+ },
+ "styles": {}
+}
```
+The wrapper section's `ui` tree, from its Repeater down. `props.children` on the Repeater and the Condition Block is required ([`author-composition-repeater-recipes`](author-composition-repeater-recipes.md) steps 2 and 4), and so is `conditionBinding`. The `section-slot` node is a leaf with `"slots": {}`, never `{ "SS1": [] }` ([`author-composition-sections`](author-composition-sections.md) § `section-slot` node shape).
+
+For Visual Editor item tags, a Box between the Repeater and the Condition Block is the general rule ([`make-visual-editor-editable`](make-visual-editor-editable.md) § 2). It has not been verified together with this pattern's `selected_field` scoping, so check the item tags after adding it.
+
Wrapper section entry also declares `linked_schemas: [{ content_type_uid: "", selected_field: "" }]` (group-multiple field, see [`author-composition-sections`](author-composition-sections.md) § The `linked_schemas`-as-reference trap).
## Fill section: Simple Section per branch
One fill section per allowed CT (references) or block (MB). No root Repeater. Its top-level node is the leaf component. `linked_schemas.selected_field` names the reference target CT (or block schema path) so the SDK scopes each iteration's item into the section's `dataSources.template`.
-```jsonc
-// ui tree of the fill section (e.g. author-card)
+```json
{
- "type": "author-card", "uid": "AC1",
+ "uid": "AC1",
+ "type": "author-card",
+ "attrs": {},
+ "metadata": { "title": "Author card" },
"props": {
"name": { "binding": { "type": "template", "value": { "path": { "name": {} } } } },
"photo": { "binding": { "type": "template", "value": { "path": { "photo": { "url": {} } } } } }
- }
+ },
+ "slots": {},
+ "styles": {}
}
```
+The fill section's leaf node (here an `author-card`), shown without the root `page` node that holds it.
+
Note the fill section's bindings use `type: "template"` (NOT `type: "repeater"`). The wrapper's SDK scoping already unwraps the iteration item into the fill section's `template` scope.
## Template: fill nodes in the placed wrapper's `slots`
The template's `section-composition` node embedding the wrapper holds each fill as a real `section-composition` node in its `slots`, keyed by the section-slot uid. Studio writes this shape when an author fills a slot:
-```jsonc
+```json
{
- "type": "section-composition",
"uid": "INST-1",
+ "type": "section-composition",
+ "attrs": {},
"metadata": { "compositionUID": "" },
+ "props": {},
"slots": {
"SS1": [
- { "type": "section-composition", "uid": "FILL-1",
+ { "uid": "FILL-1", "type": "section-composition", "attrs": {},
"metadata": { "compositionUID": "" },
- "props": {}, "slots": {}, "attrs": {}, "styles": {} }
+ "props": {}, "slots": {}, "styles": {} }
]
- // one key per CB branch's section-slot uid, each holding its own fill
- }
+ },
+ "styles": {}
}
```
+One key per CB branch's section-slot uid, each holding its own fill. A slot with no fill gets no key, not an empty array.
+
Also populate the template's `linked_sections` with references to BOTH the wrapper section entry AND every distinct fill section entry.
## Assembled: reference-iterating wrapper filled by an author fill
@@ -95,7 +127,7 @@ Also populate the template's `linked_sections` with references to BOTH the wrapp
Wrapper section linked_schemas: [{ ct: "blog_post", selected_field: "primary_author" }]
ui: Repeater(items:{path:{}}, mode:"preview")
└── ConditionBlock(condition:{type:"reference", value:"author", conditionBinding})
- └── section-slot uid:"SS1" slots:{"SS1":[]}
+ └── section-slot uid:"SS1" slots:{}
Author fill section linked_schemas: [{ ct: "blog_post", selected_field: "primary_author" }]
ui: author-card
diff --git a/skills/studio/author-composition-sections.md b/skills/studio/author-composition-sections.md
index 3692451..f2fc538 100644
--- a/skills/studio/author-composition-sections.md
+++ b/skills/studio/author-composition-sections.md
@@ -70,45 +70,21 @@ Two outcomes from this:
So you can have a section that renders perfectly on the deployed site (runtime path uses the override) yet shows empty repeaters in the editor (editor path's `linked_schemas` is empty). Both metadata paths must point to the same `selectedField` for editor + runtime to agree.
-### Recipe: section iterates one field on the page
+### Recipe: a section that renders a list
-When a section's `selectedField` is set to the iterated field (e.g. `selectedField: "products"` and the section's job is to render one card per product), the section's `dataSources.template` IS the products array. **A top-level Repeater inside the section must bind to scope-root, NOT to `{ path: { products: {} } }`**:
+Use the [canonical list recipe](author-composition-repeater-recipes.md#canonical-list-recipe): whole-entry scope (no `selected_field`), a list Box, a Repeater bound to `{ "": {} }`, an item Box, then the Condition Blocks. It is the only list shape that renders AND gives Visual Editor its list and item tags.
-```jsonc
-{
- "type": "repeater",
- "uid": "R-section-products",
- "metadata": { "mode": "preview" },
- "props": {
- "items": {
- "binding": {
- "type": "template",
- "value": { "path": {} } // ← scope-root, NOT { products: {} }
- }
- },
- "children": { "type": "slot", "slot": "" } // ← required, wires the slot below
- },
- "slots": {
- "": [
- // ConditionBlock + card here, per the multi-reference recipe;
- // card bindings still use type:"repeater" with repeaterUID
- ]
- }
-}
-```
+**Inherited sections scoped to the list field.** A section with `selectedField: "products"` has the products array as its `dataSources.template`, so its top-level Repeater must bind to scope-root `{ "path": {} }` (`getBindingStringForCS` flattens it to `""`). A path of `{ products: {} }` there resolves to `template.products`, undefined. That section renders, but Visual Editor finds no list or item tags. When you touch it, re-scope it to the whole entry and bind the Repeater to `{ products: {} }`.
-`getBindingStringForCS` flattens `{path: {}}` to `""`, so the binding resolves to `dataSources.template` directly. With `selectedField: "products"`, that IS the products array. A path of `{products: {}}` would resolve to `dataSources.template.products`, undefined when the section's template IS the array, not an object containing one.
+Reference iterations still need `data_sources.resolvedReferences` on the composition: key `"template"`, value is the array of field paths on the parent entry (e.g. `"template": ["products"]`), in the per-API form from [`author-composition-entry`](author-composition-entry.md#wire-shapes) § Wire shapes. Set them on the parent (template) composition, not the section composition.
-Reference iterations still need `data_sources.resolvedReferences` on the composition: key `"template"`, value is the array of field paths on the parent entry (e.g. `"template": ["products"]`). The runtime resolves these along the `selectedField` path. You set them on the parent (template) composition, not the section composition.
-
-### Decision rule: single-field repeater section vs whole-entry section
+### Decision rule: list section vs whole-entry section
| Section's job | `selectedField` | Repeater binding (if any) | When to use |
|---|---|---|---|
-| One repeater over one field of the page (a card grid, a feature list) | Set to that field (`"products"`) | Scope-root `{ path: {} }` | Most reusable sections: Editions list, Features grid, Card grid |
+| A list over one field of the page (a card grid, a feature list) | **Leave unset**: whole entry | `template` path `{ "": {} }` | Every list section. [Canonical list recipe](author-composition-repeater-recipes.md#canonical-list-recipe) |
| A custom component reading several fields off the page entry (header reading brand + nav_links + signin_label. Hero reading the `hero` group) | **Leave unset**: section's `template` is the whole page entry | Original `template.` paths work verbatim | Single-purpose sections with no iteration: Header, Hero, static 3D block |
-
-The choice is data-shape-driven: one field iterated calls for `selectedField` plus a scope-root. Many fields read calls for whole-entry scope and the original paths.
+| One item of a top-level Group or single reference | Set to that field | None | A section that reads one nested object, not a list |
### Verifying a section's scope headlessly
@@ -123,23 +99,26 @@ console.log(JSON.stringify(spec.data.section_scoped_data[""], null
`spec.data.section_scoped_data[]` is the runtime resolution: `selectedField` + the actual scoped `template` array. If `template` shows the right array length and right entry sample, the section's scope is correct. If it's `undefined` or wrong-shaped, the bindings won't work.
-## `section-slot` node shape: the one legitimate empty-slots node
+## `section-slot` node shape: a leaf with no slot key
-The built-in `section-slot` type declares a **placeholder** inside a section's `ui`. The template that embeds this section then fills the slot via a `section-composition` node (see next recipe: Pattern B). Non-obvious shape:
+The built-in `section-slot` type declares a **placeholder** inside a section's `ui`. The template that embeds this section then fills the slot via a `section-composition` node (see next recipe: Pattern B). It is a leaf: write the full seven-key envelope with an **empty `slots` object**:
-```jsonc
+```json
{
- "type": "section-slot",
"uid": "SS1",
+ "type": "section-slot",
+ "attrs": {},
+ "metadata": { "title": "Section Slot — Card", "visible": true, "locked": false },
"props": {},
- "slots": { "SS1": [] } // ← key is the node's OWN uid; value is [] until filled
+ "slots": {},
+ "styles": {}
}
```
Two things that trip API authors:
-1. **`slots.: []` is required**: the section-slot's own uid appears as the slot key, with an empty array. This is one of only two legitimate exceptions to the preflight rule "no empty slot arrays". The other is the page-side `section-composition` node, where Studio writes `slots.: []` for each slot the template hasn't filled yet. Omitting the entry (no `slots` at all, or `slots: {}`) leaves the template unable to target the slot when composing.
-2. **The slot is filled by the TEMPLATE, not the section.** In the section's authored tree the slot stays empty. The template's `section-composition` node holds the fill in its own `slots`, keyed by the section-slot uid ([Pattern B](author-composition-section-slot-fill.md)).
+1. **No `slots.` entry.** `"slots": { "SS1": [] }` is rejected by the Compositions API with `422 Empty slots crash the renderer.` The raw CMA accepts it, so the same shape can reach the renderer that way. The node's uid is already the key the template fills under, and the SDK renders `section-slot` from the template's fill, never from the node's own `slots`. There is no exception to the preflight rule "no empty slot arrays" ([`author-composition-encoding`](author-composition-encoding.md) § Preflight).
+2. **The slot is filled by the TEMPLATE, not the section.** In the section's authored tree the slot stays empty. The template's `section-composition` node holds the fill in its own `slots`, keyed by the section-slot uid ([Pattern B](author-composition-section-slot-fill.md)). A slot the template hasn't filled yet has no key there either.
Section slots typically sit inside a Repeater + Condition Block on the wrapper section (Pattern B for reference / MB iteration) or directly under the section root (Pattern B for a simpler single-slot section).
diff --git a/skills/studio/author-composition-static-values.md b/skills/studio/author-composition-static-values.md
index f40f70f..5c04b4c 100644
--- a/skills/studio/author-composition-static-values.md
+++ b/skills/studio/author-composition-static-values.md
@@ -23,3 +23,4 @@ Consequences for API authoring:
- **Widget-to-subtype pairing.** The node's `props..type` (`"string"` / `"number"` / `"href"` / `"imageurl"` / `"choice"` / …) determines which sub-bucket the SDK looks in. `string` is the exception: its literal never goes in `static_value.string`. A single-line string goes in **`text`**, a multiline string (textarea widget) in **`textarea`**, as in the template recipe's `headline` and `subhead`. Verify the bucket against the registered schema or a working composition before writing. Other types use the bucket of the same name. If you write the key to the wrong subtype array, the resolver misses and the prop renders as the schema default.
- **`type` also decides whether the binder FLATTENS the value: never write `"any"` for structured data.** The binder runs a recursive single-key unwrap on every type except `object` and `array` (`retrieve-data.ts`: `if (type !== "object" && type !== "array" …) resolvedData = getFlattenedData(resolvedData)`), descending while an object has exactly one key (`$` and `_metadata` don't count). So a node prop typed `"any"` and bound to a single-field block (`image_grid → { image: [...] }`) receives the **inner array**, not the block: lookups return `undefined`, indices surface as `{0,1,2,3}`, and it renders blank with no error. Write `"object"` for a group / Global Field / block object and `"array"` for a list. Those two skip the unwrap. Full mechanism: [`register-component` § The `any` flatten trap](register-component.md).
- **Convention Studio uses:** keys are `-` (e.g. `mHjnXsgKbBlj_PZ-headline`). Not required but stable and readable.
+- **Leading and trailing spaces are trimmed.** A static value of `", "` renders as `","`, so a separator between two bound values collapses: `Hale,Co-founder`. Write a non-breaking space instead (`"\u00a0"` in the JSON payload, so `",\u00a0"`) where an edge space must survive, or better, give the component a `prefix` / `separator` prop and let it add the spacing in code.
diff --git a/skills/studio/author-composition-templates.md b/skills/studio/author-composition-templates.md
index 89c04fd..1be3675 100644
--- a/skills/studio/author-composition-templates.md
+++ b/skills/studio/author-composition-templates.md
@@ -190,13 +190,15 @@ The `ui` value on the wire is the tree above, JSON-stringified, `zlib.compress`e
"datestring":[],
"json_rte": [],
"html_rte": [],
- "object": [],
- "ui": "zlib:eNqtlFtvgjAUgP9L…"
- }
+ "object": []
+ },
+ "ui": "zlib:eNqtlFtvgjAUgP9L…"
}
}
```
+`ui` is a top-level entry field, a sibling of `static_value`, never inside it. The `number` value is a bare `49` because this stack's compositions CT types `static_value.number.value` as `number`. On a CT built from [`provision-studio-project`](provision-studio-project.md) it is text, so write `"49"` ([`author-composition-entry`](author-composition-entry.md#wire-shapes) § Wire shapes).
+
Studio-generated `_metadata.uid` on each `static_value.*` entry is added by the CMA on write. Do not populate it yourself.
### Python builder: produces the exact body above
@@ -246,7 +248,7 @@ body = {
{"key": "mHjnXsgKbBlj_PZ-ctaHref", "value": ""},
{"key": "Wo-7Ly0xHXWsOXQ-ctaHref", "value": "https://example.com"},
],
- "number": [{"key": "Wo-7Ly0xHXWsOXQ-price", "value": 49}],
+ "number": [{"key": "Wo-7Ly0xHXWsOXQ-price", "value": 49}], # "49" if the CT types it as text
"choice": [{"key": "Wo-7Ly0xHXWsOXQ-badge", "value": []}],
"array": [], "boolean": [], "any": [], "datestring": [],
"json_rte": [], "html_rte": [], "object": [],
diff --git a/skills/studio/author-composition-thumbnails.md b/skills/studio/author-composition-thumbnails.md
index 1b84cfc..0a098a0 100644
--- a/skills/studio/author-composition-thumbnails.md
+++ b/skills/studio/author-composition-thumbnails.md
@@ -14,13 +14,13 @@ Part of the API-authoring set. Start at [`author-composition-via-api`](author-co
**Symptom:** every API-authored Section shows a blank/placeholder tile in Studio's **Sections** accordion, while UI-authored ones show a picture of the section. Nothing errors.
-**Why.** Studio's Sections accordion renders `section.ui_preview.url` (`ui_preview` is a `file` field holding an asset). When an author saves a Section **in the Studio UI**, the app screenshots the canvas node, converts the base64 to a blob, uploads it as an asset, and PUTs `{ entry: { ui_preview: } }` on the composition entry, fire-and-forget, master locale, then invalidates the sections query so the tile appears without a reload. **That whole chain lives in the editor.** A composition entry created by CMA has `ui_preview` empty forever.
+**Why.** Studio's Sections accordion renders `section.ui_preview.url` (`ui_preview` is a `file` field holding an asset). When an author saves a Section **in the Studio UI**, or opens one that has no thumbnail, the app screenshots the canvas node, converts the base64 to a blob, uploads it as an asset, and PUTs `{ entry: { ui_preview: } }` on the composition entry, fire-and-forget, master locale, then invalidates the sections query so the tile appears without a reload. **That whole chain lives in the editor.** A Section created through the API or the CMA has `ui_preview` empty until someone opens or saves it in Studio.
Same asymmetry family as `linked_sections`: the UI populates a field behind the scenes, so API authoring must do it explicitly.
### Fix A: zero-script backfill (best when the sections already exist)
-Open each Section in Studio and hit **Save**, no edits needed. The screenshot/upload/PUT chain runs on save and backfills `ui_preview` with a real render of the section. Cheapest correct fix, and the thumbnail is an actual screenshot rather than a placeholder. Only worth scripting instead when the section count makes clicking impractical.
+Open each Section in Studio, no edits needed. A Section with no thumbnail gets one once the canvas has rendered it, and **Save** takes one too. A Section whose props bind to entry data gets none on open until you pick a preview entry for it. The screenshot/upload/PUT chain backfills `ui_preview` with a real render of the section. Cheapest correct fix, and the thumbnail is an actual screenshot rather than a placeholder. Only worth scripting instead when the section count makes clicking impractical.
### Fix B: pick an image in the Edit Section modal (no canvas render, no script)
diff --git a/skills/studio/author-composition-via-api.md b/skills/studio/author-composition-via-api.md
index 7d6379e..ddfd60d 100644
--- a/skills/studio/author-composition-via-api.md
+++ b/skills/studio/author-composition-via-api.md
@@ -40,7 +40,7 @@ If any of these is missing, this skill is the wrong starting point.
> **Auth: OAuth first.** Snippets here write `-H "$CS_AUTH"`, resolved once per run. **Assume there is no session `authtoken`**. The user authenticates with OAuth (MCP `--auth` / `csdx auth:login --oauth`) and never pastes one. Resolution order, scope headers (`organization_uid` vs `api_key`), the expiry/refresh rule and the 401/422 signature table: [`authenticate-cma`](authenticate-cma.md).
-> **MCP-first execution.** Discover tools and verify the target with a read call per [`install-contentstack-mcp`](install-contentstack-mcp.md). When supported tools are callable, the entry write + publish run as MCP tool calls (`create_an_entry` / `update_an_entry` / `publish_an_entry`) with no pasted `authtoken`. The composition **shape** is unchanged: you still build the `ui` (`zlib:`), `data_sources`, `static_value`, and `composable_uid` (= entry uid) exactly as below. The MCP only replaces the raw CMA transport. **Shell-corruption caveat on the MCP path:** `create_an_entry` takes `entry_data` as an **inline JSON object: there is no file-path param**, so the temp-file / `curl -d @file` guard does NOT apply. Instead **build the entire `entry_data` object programmatically and hand it to the tool, never hand-transcribe the `ui` blob into the arguments**, and assert the `"children"` count in the tree before the call (see the shell-corruption pitfall). If hand-assembly is unavoidable, keep the raw `curl -d @file` path for the write. A missing readiness flag requires discovery; use HTTP only for unavailable operations or a recorded connection limitation.
+> **MCP-first execution.** Discover tools and verify the target with a read call per [`install-contentstack-mcp`](install-contentstack-mcp.md). When supported tools are callable, the entry write + publish run as MCP tool calls (`create_an_entry` / `update_an_entry` / `publish_an_entry`) with no pasted `authtoken`. The composition **shape** is unchanged: you still build the `ui` (`zlib:`), `data_sources`, `static_value`, and `composable_uid` (= entry uid) exactly as below. The MCP only replaces the raw CMA transport. `entry_data` is wrapped like the HTTP body: `{ "entry": { … } }`. **If the compositions CT was created in this session, its entry create, update and publish go over HTTP until Claude Code restarts**: the MCP only accepts content types that existed when it started ([`install-contentstack-mcp`](install-contentstack-mcp.md#cts-created-after-start) Step 6b). **Shell-corruption caveat on the MCP path:** `create_an_entry` takes `entry_data` as an **inline JSON object: there is no file-path param**, so the temp-file / `curl -d @file` guard does NOT apply. Instead **build the entire `entry_data` object programmatically and hand it to the tool, never hand-transcribe the `ui` blob into the arguments**, and assert the `"children"` count in the tree before the call (see the shell-corruption pitfall). If hand-assembly is unavoidable, keep the raw `curl -d @file` path for the write. A missing readiness flag requires discovery; use HTTP only for unavailable operations or a recorded connection limitation.
## The API-authoring set: read the one you need
@@ -77,14 +77,14 @@ This skill succeeds only when ALL are true.
- [ ] **Every `place_composition_as: "page"` entry has BOTH `url` and `url_metadata`**, `url_source` matching how the pattern is derived (`content_type_url_pattern` for Connected, `default_url_pattern` / `user_specified_pattern` for Freeform), `url_queries` as a JSON string. **Read the entry back after the write** and confirm `url_metadata` is present: a compositions CT missing the group makes the CMA drop it silently, leaving the composition on `legacy_url`.
- [ ] `data_sources.resolvedReferences` is set per reference field the composition iterates.
- [ ] Every Repeater node has `metadata.mode: "preview"` (for canvas iteration).
-- [ ] Every Repeater iterating a reference or modular block has a Condition Block as its immediate child.
+- [ ] Every list follows the [canonical list recipe](author-composition-repeater-recipes.md#canonical-list-recipe): whole-entry scope, list Box with `repeaterWrapper: true`, Repeater bound to the field, an item Box as its only child, and (for references and modular blocks) the Condition Blocks inside that Box.
- [ ] **Every Condition Block's `metadata.condition` has `operator` and `conditionBinding`**, and the composition was checked in **preview mode**. Without the binding the block renders nothing, but design mode still shows it.
- [ ] Card / iteration child bindings use `type: "repeater"` with `repeaterUID`, NOT `type: "template"` with `.0.` fixed-index paths.
- [ ] **Every Section composition created in this run has `ui_preview` set, verified by re-reading each entry.** A section with a blank tile is an unfinished section. Do not report the work as done. Also true of sections created as a side effect (clones, rescopes, batch builds).
- [ ] **Every Section composition has `ui_preview` set**, an uploaded, **published** asset uid, verified by re-reading the entry (`.entry.ui_preview.url` resolves). Blank tiles in Studio's Sections accordion otherwise. Fastest fix is often opening each section in Studio and pressing Save. See § [Section thumbnails](author-composition-thumbnails.md#section-thumbnails-ui-preview)
- [ ] **Envelope complete**: every node in the written tree has all seven keys (`uid`, `type`, `props`, `metadata`, `slots`, `attrs`, `styles`), verified by re-reading the entry. A missing `slots` renders fine and white-screens the Data tab later.
-- [ ] **Preflight passed**: every `slots[]` is a non-empty array (exception: `section-slot` nodes carry `slots: { "": [] }` by design). No orphan descendant references in the tree.
-- [ ] **[Pattern B](author-composition-section-slot-fill.md) (if used)**: wrapper section has `section-slot` node with `slots:{"":[]}`. Fill section's leaf bindings use `type:"template"` (NOT `repeater`). Template's `section-composition` node has `slots.: []`. Template's `linked_sections` includes BOTH wrapper and fill entries.
+- [ ] **Preflight passed**: every `slots[]` is a non-empty array, no exceptions (`section-slot` nodes carry `"slots": {}`). No orphan descendant references in the tree.
+- [ ] **[Pattern B](author-composition-section-slot-fill.md) (if used)**: wrapper section has a `section-slot` node with `"slots": {}`. Fill section's leaf bindings use `type:"template"` (NOT `repeater`). Template's `section-composition` node has `slots.: []`. Template's `linked_sections` includes BOTH wrapper and fill entries.
- [ ] **Every `section-composition` node's `metadata.compositionUID` is the placed section's entry uid** (`blt…`), never its `composable_uid`. A `composable_uid` there fails in the canvas with `Component with type '' is not registered`.
- [ ] **A section whose `selected_field` is a multiple group / global field / file / link** has `cardinality` from the user's answer (asked, never assumed): `"collection"` with a root Repeater, or `"single"` without one. Reference and modular-block sections carry no `cardinality` ([`understand-linked-schemas`](understand-linked-schemas.md)).
- [ ] **Exposed section props (if used)**: `sectionExposedProps` array on section's `ui` ROOT `metadata`. Template's `section-composition.props` map keyed by each exposure's `uid`. Per-instance override values use distinct static_value keys.
diff --git a/skills/studio/build-repeating-section.md b/skills/studio/build-repeating-section.md
index 21917d3..d134028 100644
--- a/skills/studio/build-repeating-section.md
+++ b/skills/studio/build-repeating-section.md
@@ -33,7 +33,7 @@ Before reaching for the parent+child+Repeater pattern, check whether a **single
The pattern:
-1. **Register a component** with an `array`-typed prop (e.g. `cards: { type: "array" }`).
+1. **Register a component** with an `array`-typed prop (e.g. `cards: { type: "array", items: { type: "any" } }`; `items` is required on studio-react 2.x).
2. **Bind that prop to a Reference field** on the section's linked schema.
3. **Populate `data_sources.resolvedReferences`** in the composition. This tells the SDK to include the referenced entries via CDA `?include[]=`, so the bound array arrives at the component as **full resolved entry objects**, not `{uid}` stubs.
4. **The component does its own `.map()`** to render each item, no Studio Repeater, no Condition Block, no iteration scope inside Studio.
@@ -44,7 +44,7 @@ registerComponent({
type: "doc-card-list",
component: CardList,
props: {
- cards: { type: "array" }, // <-- bound to a multi-Reference field on the section's linked schema
+ cards: { type: "array", items: { type: "any" } }, // <-- bound to a multi-Reference field on the section's linked schema
},
});
@@ -95,7 +95,7 @@ Gate: open the child standalone. It must adapt at any canvas width. If it render
In Studio, open the Sections tab, then click + New Section. Name it `parentSectionName`.
-Link the parent to `parentLinkedSchema` (the CT / Global Field that holds `iterationSourceField`). Use the same Connect-A-Schema flow `build-section` describes.
+Link the parent to `parentLinkedSchema` (the CT / Global Field that holds `iterationSourceField`). Use the same Connect-A-Schema flow `build-section` describes. **Link the whole schema, don't select `iterationSourceField` as the section's field.** A section scoped to the list field renders, but Visual Editor gets no list or item tags. This step and Steps 3 to 6 are the [canonical list recipe](author-composition-repeater-recipes.md#canonical-list-recipe) built in the UI.
### Step 3: Drop a layout container Box first
@@ -108,7 +108,15 @@ Link the parent to `parentLinkedSchema` (the CT / Global Field that holds `itera
| `vertical-list` | `display: flex; flex-direction: column; gap: 16px;` |
| `horizontal-scroll` | `display: flex; gap: 16px; overflow-x: auto; scroll-snap-type: x mandatory;` with `flex: 0 0 280px` on the cell |
-The Box defines the cell size. The Slot will sit inside the Repeater inside this Box, so each iteration fills one grid track / flex item / list row.
+The Box defines the cell size. The Slot will sit inside the Repeater inside this Box, so each iteration fills one grid track / flex item / list row. Always space items with `gap` on this Box, never with sibling selectors: see § The Repeater's wrapper element below.
+
+#### The Repeater's wrapper element
+
+The Repeater wraps each item in its own ``. A list Box rendered as `
` with `- ` items becomes `ul > div > li`:
+
+- **Sibling- and child-based CSS stops matching.** `space-y-*` / `space-x-*`, `divide-*`, `> * + *` margins, `li:first-child`, `:nth-child()` and `ul > li` all see the wrapper divs, not the items. Margins on the wrapper do nothing, because a `display: contents` element has no box.
+- **Flex and grid still work.** `display: contents` lifts each item into the list Box's layout, so `display: flex` / `grid` with `gap` spaces the items exactly as authored. Use that for every list container.
+- **`ul > div` is invalid list markup.** Screen readers may stop announcing the list and its count. If list semantics matter, render the list Box as a plain `div` and give it `role="list"` with `role="listitem"` on each item's element, or keep the `ul`/`li` inside a registered component that maps the array itself (§ When to skip the Repeater entirely).
### Step 4: Drop a Repeater inside the Box
@@ -118,18 +126,17 @@ Bind the Repeater's **Bind items** property to `iterationSourceField` via the Da
For multi-reference / Modular Block iteration, see Step 5 (Condition Block). For `group` / `array` / `pinned-query`, skip to Step 6.
-### Step 5: Wrap in a Condition Block (when required)
+### Step 5: Drop an item Box, then a Condition Block (when required)
-If `iterationItemKind` is `reference` (single-CT or multi-CT) or `modular-block`, the Repeater's immediate child must be a **Condition Block** (Studio schema requirement); for Visual Editor's item controls, those may sit inside one Box that is the Repeater's only child ([`make-visual-editor-editable`](make-visual-editor-editable.md) § 2). Two ways:
+Drop a **Box** inside the Repeater as its only child, whatever `iterationItemKind` is. Visual Editor's per-item tag (move, delete, add) lands on that Box. Without it, a Condition Block holding a Section or Section Slot drops every item tag ([`make-visual-editor-editable`](make-visual-editor-editable.md) § 2).
-- **Preferred**: drop a Section Slot inside the Repeater (Step 6), then when you later drop the child Section into the Slot at template time, Studio surfaces an inline hint that this iteration source needs a Condition Block. Accept the wrap and Studio inserts it for you.
-- **Manual**: drop a Condition Block inside the Repeater first. Then drop the Section Slot inside the Condition Block.
+If `iterationItemKind` is `reference` (single-CT or multi-CT) or `modular-block`, drop a **Condition Block** inside the item Box, one per content type or block. Studio's inline "needs a Condition Block" prompt (Step 8) inserts one directly under the Repeater, without the Box, so add it manually here instead.
-For `group` / `array` / `pinned-query`, no Condition Block is needed.
+For `group` / `array` / `pinned-query`, no Condition Block is needed: the Section Slot goes straight into the item Box.
-### Step 6: Drop exactly ONE Section Slot inside the Repeater (or its Condition Block)
+### Step 6: Drop exactly ONE Section Slot inside the Condition Block (or the item Box)
-Drag a **Section Slot** from the palette into the Repeater (or the Condition Block from Step 5). One Slot, not multiple.
+Drag a **Section Slot** from the palette into the Condition Block from Step 5 (or the item Box, when there is no Condition Block). One Slot per branch, not multiple.
Set the Slot's **Drop placeholder label** to something intent-revealing: `Drop ${childSectionName} here` (e.g. `Drop ProductCard here`). The label is the only contract template authors have with this Section.
@@ -140,11 +147,12 @@ Set the Slot's **Drop placeholder label** to something intent-revealing: `Drop $
Save. In Layers, the tree should read:
```
-Section ""
-└── Box (layoutShape container)
+Section "" (linked to the whole schema)
+└── Box (layoutShape container, gap)
└── Repeater (bound to )
- └── (Condition Block, if reference/modular-block)
- └── Section Slot (label: Drop here)
+ └── Box (item)
+ └── (Condition Block, if reference/modular-block)
+ └── Section Slot (label: Drop here)
```
In the parent Section's own canvas (with no template open), the Repeater shows one placeholder iteration containing the empty Slot. This is normal. Fills happen at template / parent-of-parent time.
@@ -158,7 +166,7 @@ What happens:
- Section-level auto-binding kicks in: the child's linked schema (`childLinkedSchema`) is matched to the Repeater's iteration scope. The child Section's bindings resolve against each iterated item.
- Zero manual binding clicks. The child Section is pre-bound by virtue of being a Section.
-If the iteration source is a single-CT / multi-CT Reference (or Modular Block) and you didn't add a Condition Block manually in Step 5, Studio prompts you to wrap. Accept.
+If the iteration source is a single-CT / multi-CT Reference (or Modular Block) and you didn't add a Condition Block in Step 5, Studio prompts you to wrap. Accepting puts the Condition Block directly under the Repeater, so move it into the item Box afterwards.
### Step 9: Toggle Preview Mode to verify
@@ -190,7 +198,9 @@ This skill succeeds only when ALL are true:
- [ ] **BOTH Sections (child and parent) have a `ui_preview`**, a blank tile in the Sections accordion otherwise. UI-created sections get one from Studio on Save. API-created ones need one attached explicitly. See [`author-composition-via-api` § Section thumbnails](author-composition-thumbnails.md#section-thumbnails-ui-preview).
- [ ] The parent Section's Box has explicit layout CSS for `layoutShape` (grid tracks, flex with basis, etc.) defining each cell's size.
- [ ] The Repeater is bound to `iterationSourceField` via the Data Picker (confirmed by inspecting the Bind items breadcrumb).
-- [ ] Exactly ONE Section Slot sits inside the Repeater (or its Condition Block).
+- [ ] Exactly ONE Section Slot sits inside each Condition Block (or the item Box).
+- [ ] The parent Section is linked to the whole schema, not scoped to `iterationSourceField`.
+- [ ] The list Box spaces items with flex/grid `gap`, not sibling selectors (`space-y-*`, `> * + *`).
- [ ] When the parent Section is dropped on a template and the child Section is dropped into the Slot, **no manual prop binding** is needed. The child binds automatically at Section level.
- [ ] Preview Mode on the Repeater renders N iterations of the child Section, each iteration's bindings showing the corresponding item's data.
- [ ] No iteration renders full-bleed (parent Box defines the cell size correctly).
@@ -202,7 +212,8 @@ This skill succeeds only when ALL are true:
| Dropping a raw registered card into the Slot | Build it as a Section first. Drop the Section. See `understand-section-slots`. |
| Skipping the Box (Step 3), Repeater bare on Section root | Wrap Repeater in a sized Box. See `use-section-slot` § Layout container. |
| Multiple Section Slots inside the Repeater | Exactly ONE Slot per Repeater. Put multi-region content inside the child. |
-| Skipping Condition Block for Reference / Modular Block iteration | Accept Studio's inline wrap prompt, or insert Condition Block manually (Step 5). |
+| Skipping Condition Block for Reference / Modular Block iteration | Insert it inside the item Box (Step 5). Studio's inline wrap prompt puts it under the Repeater instead: move it. |
+| `space-y-*`, `divide-*` or `li + li` spacing on the list | The Repeater's per-item `display: contents` wrapper breaks sibling selectors. Use flex/grid `gap` (§ The Repeater's wrapper element). |
| Binding from parent's linked-schema root inside the Repeater | Inside Repeater, picker shows "Repeater Data". Bind from that root. See `use-repeater` § The scope rule. |
| Child depends on layout ancestor (`.fs-grid > .fs-card`) | Keep child layout-agnostic. See `register-component` § Layout contract. |
| Empty Slot in Design Mode mistaken for binding error | Toggle Preview Mode. See `use-repeater` step 7. |
diff --git a/skills/studio/build-section-component-choice.md b/skills/studio/build-section-component-choice.md
index fa8b305..3f6dd7c 100644
--- a/skills/studio/build-section-component-choice.md
+++ b/skills/studio/build-section-component-choice.md
@@ -12,15 +12,15 @@ Part of the Section-authoring set. Start at [`build-section`](build-section.md).
Studio palettes ship two kinds of nodes you can drop into a Section. **All editing happens in the right panel. Neither kind supports inline-typing in the canvas.** The real tradeoff is shape and styling:
-| | **Basic field components** (palette: Basic): `Heading`, `Paragraph`, `Text`, `Image`, `Link`, `RichText`, etc. | **Custom registered components** (palette: Registered Components): your ``, ``, etc. |
+| | **Basic field components** (palette: Basic): `header`, `text`, `image`, `link`, `rich-text`, etc. (SDK type names; see [`plan-studio-architecture`](plan-studio-architecture.md#built-in-names)) | **Custom registered components** (palette: Registered Components): your ``, ``, etc. |
|---|---|---|
-| Shape | Single value (a string for `Heading` / `Paragraph` / `Text`, a URL for `Image`, a JSON tree for `RichText`). The author sets it via the right panel: either as a **static value** typed inline, or as a **binding** to a CT field. | N props as declared in `registerComponent` schema. Each prop set via the right panel: static value or binding to a CT field. |
+| Shape | Single value (a string for `header` / `text`, a URL for `Image`, a JSON tree for `rich-text`). The author sets it via the right panel: either as a **static value** typed inline, or as a **binding** to a CT field. | N props as declared in `registerComponent` schema. Each prop set via the right panel: static value or binding to a CT field. |
| Visual styling | Plain HTML by default: `
`, `
`, `
`. Styling comes from node `styles.default.responsiveStyles.default` set via the Design panel, OR app-side CSS targeting the rendered DOM. RTE embeds use the default serializer unless you register a custom one (see `register-json-rte`). | Whatever your React + CSS produces. Pixel-perfect. Uses your design system. |
| Layout authoring | Composable via Basic `Box` + node `responsiveStyles` (see pitfall row below). | Owned by the React component code. |
| Authoring UX | Quick: drop, type a value or bind a field in the right panel, done. Good for editorial copy that lives ONLY in the composition (not modelled in the CMS). | Higher-fidelity: drop a fully-styled block, bind props to CT fields. Right-panel surface is exactly the prop schema you registered. |
| Best for | Headlines, body copy, captions, CTA labels, RTE blocks, wherever literal copy can live in the composition itself OR map 1:1 to a CT field with no surrounding shell. | Marquee / styled / interactive sections: hero, carousel, 3D viewer, anything pixel-sensitive or with multi-field internal structure. |
-**These are two render paths.** The local standalone page (custom `Hero`) and the Studio composition (Basic `Heading` + `Paragraph`) will NOT look identical by default. Treat the gap as design work, not a bug.
+**These are two render paths.** The local standalone page (custom `Hero`) and the Studio composition (Basic `header` + `text`) will NOT look identical by default. Treat the gap as design work, not a bug.
**Recommended pattern: hybrid.** Use both inside one Section:
@@ -36,7 +36,7 @@ Decision rule:
### Migrating an existing component: register it whole, or decompose it?
-When a Section is being built from JSX that already exists, there's a prior question the rule above doesn't answer: does the whole `` become **one registered component**, or does it get **decomposed into built-ins** (`Box` + `Image` + `Heading` + `Paragraph` + `Link`) that reproduce the same markup?
+When a Section is being built from JSX that already exists, there's a prior question the rule above doesn't answer: does the whole `` become **one registered component**, or does it get **decomposed into built-ins** (`box` + `image` + `header` + `text` + `link`) that reproduce the same markup?
A Section made of one registered component is a **black box to authors**: they can re-bind its props and nothing else. No restyling, no reordering its internals, no removing the subtitle. That's often the right trade, but it must be a stated choice, not a default that happens because registering was easier.
diff --git a/skills/studio/build-section.md b/skills/studio/build-section.md
index 3f6671c..8be59fd 100644
--- a/skills/studio/build-section.md
+++ b/skills/studio/build-section.md
@@ -81,7 +81,7 @@ The section's own canvas therefore always shows **one empty placeholder** (no pa
| Section shape | Rule | Where its bindings resolve against |
|---|---|---|
-| **One Repeater over one field** (Card Grid over `related_posts`, feature list over `features[]`) | Set `selectedField=` + use scope-root repeater binding `{ path: {} }` inside | `template` becomes the field's array value directly |
+| **One Repeater over one field** (Card Grid over `related_posts`, feature list over `features[]`) | Keep whole-entry scope and bind the Repeater to `{ : {} }`, per the [canonical list recipe](author-composition-repeater-recipes.md#canonical-list-recipe) | `template` stays the entry; the Repeater iterates the field |
| **Multiple fields on the page entry** (Header reading `brand` + `nav_links` + `signin_label`, Hero reading a `hero` group) | **Leave `selectedField` unset**: the section needs the whole entry | `template` is the full page entry. Keep original `template.` binding paths |
Setting `selectedField` on a multi-field section loses access to everything outside the scoped path. Bindings to those fields resolve to `undefined`.
diff --git a/skills/studio/byoc-end-to-end.md b/skills/studio/byoc-end-to-end.md
index 8bcffd7..78b435e 100644
--- a/skills/studio/byoc-end-to-end.md
+++ b/skills/studio/byoc-end-to-end.md
@@ -56,14 +56,14 @@ Gate: open Studio, open any composition (even an empty one). Canvas iframe loads
### Phase 1: Register components (lazy by default)
-- **`register-component`**: for each component the user wants Studio to surface in the palette. Default to the LAZY shape: `component: () => import("./Foo").then(m => ({ default: m.Foo }))`. Eager only when the component is tiny and on a hot path where Suspense fallback is unacceptable.
+- **`register-component`**: for each component the user wants Studio to surface in the palette. Default to the LAZY shape: `registerLazyComponent(config, () => import("./Foo").then((m) => m.Foo))`, the loader resolving to the component itself. Every `object` prop needs `properties`, every `array` prop needs `items`. After changing any registration, restart the dev server before checking the canvas. Eager only when the component is tiny and on a hot path where Suspense fallback is unacceptable.
- **`register-breakpoints`** (optional): if the site needs custom responsive breakpoints registered for Studio's responsive editor.
- **`register-json-rte`** (optional): only if the site uses JSON-RTE-bound props.
- **`import-design-tokens`**: register the customer's design system. Do this BEFORE building any Section. Tokens here drive visual fidelity of every later canvas preview.
Detour: `component: Hero` where `Hero` is `function Hero() { ... }` (no `props` arg) produces "Invalid hook call". Back to `register-component`, read the arity rule. The lazy shape avoids the trap.
-Gate: open the canvas. Switch the palette to **Registered Components**. Every component the user expects to see is present, with a non-blank preview (defaults populated).
+Gate: open the canvas. Switch the palette to **Registered Components**. Every component the user expects to see is present, with a non-blank preview (defaults populated). Without the Studio UI, run the dev-only registry check from `register-component` step 7 on `/canvas`: every expected `type` is listed.
### Phase 2: Build the first Section
@@ -122,7 +122,7 @@ Phase 0 includes project bootstrap. Recommended: Next.js App Router (catch-all r
| Studio canvas blank, no console error | env Base URL empty, or Canvas URL set to a full URL instead of a path | `setup-section-preview` + `understand-canvas-url` |
| Components don't show up in the palette | Registration file isn't imported at app startup | `register-component`: `import "./register-components"` from the app entry |
| Sections panel is empty even after Phase 2 finished | Wrong project selected in Studio top-bar | switch project, OR re-run `provision-studio-project` if it was provisioned against a different stack |
-| "Internal components missing: page" on the visitor route | SDK predates the release that auto-registers built-ins on the visitor render path | `upgrade-studio-sdk`: bump to the patch that ships the visitor-path auto-register |
+| `COMPONENT_NOT_REGISTERED` naming `page` on the visitor route | The visitor bundle never imports `@contentstack/studio-react` | `troubleshoot-ssr-rendering`: add that import above the fetch |
| Template renders in Studio but not on the live route | The visitor route isn't mounting ``, OR is mounting `` by mistake | `understand-canvas-vs-component` + `setup-template-preview-routes` |
| "registered as lazy but hasn't been loaded yet" | `fetchSpec` / `fetchCompositionData` wasn't awaited before `` mounted | `configure-csr-vs-ssr`: the SSR / RSC patterns show the right await placement |
| `useData()` warning in dev console only | Benign. Basic component hit the SSR pass before `DataCtxProvider` attached. Disappears post-hydration. | `troubleshoot-ssr-rendering`: confirm it's a warning, not an error |
diff --git a/skills/studio/complete-the-build.md b/skills/studio/complete-the-build.md
index 56382a1..f6c3513 100644
--- a/skills/studio/complete-the-build.md
+++ b/skills/studio/complete-the-build.md
@@ -33,7 +33,7 @@ Each is owned by exactly one skill. This file is the checklist and the gate: rea
| 9 | Live Preview `mode: "builder"` (with `editInVisualBuilderButton: { enable: false }`) | once per project | [`install-studio`](install-studio.md) | Visual Editor opens on "Initializing the editor…" and nothing is clickable. Nothing errors |
| 10 | A **new** entry is editable on the page: `fetchTemplateEntry` completes it, lists carry `` and `.` tags | **every** Template | [`make-visual-editor-editable`](make-visual-editor-editable.md) | A new entry shows "Your text here", its empty fields and image can't be clicked, and list items can't be added, moved or deleted |
-**Why this matters.** Every failure above is silent and asymmetric: the developer's own loop (SSR renders, unit tests, a page in the browser) passes in all six cases, because none of them affect rendering. They only affect the editor, which is the audience the entire exercise exists for. That asymmetry is why these get skipped, and why they need a gate rather than good intentions.
+**Why this matters.** Every failure above is silent and asymmetric: the developer's own loop (SSR renders, unit tests, a page in the browser) passes in all ten cases, because none of them affect rendering. They only affect the editor, which is the audience the entire exercise exists for. That asymmetry is why these get skipped, and why they need a gate rather than good intentions.
## The two thumbnail systems are not the same thing
@@ -48,7 +48,7 @@ They are constantly conflated, and fixing one does nothing for the other.
Between the two, **nothing in the Components panel is ever a blank tile.** That is the acceptance bar, not "most tiles have pictures".
-### Unpublished is the hardest of the runtime seven to see
+### Unpublished is the hardest of the ten to see at runtime
(Deliverable 8 is invisible in a different way: it never misrenders at all. Only a node count finds it.)
@@ -84,6 +84,25 @@ grep -rn 'mode: "builder"\|fetchTemplateEntry'
curl -s "" | grep -o 'data-cslp="[^"]*"' | sort -u
```
+**What the author sees in the Components panel (#3), checked without opening Studio.** The project's registered-components list in Studio stays empty for a code-registered library: registration happens in the browser, at runtime. To see the palette headlessly, read the registry on the `/canvas` page in development. `getComponentRegistryInstance` is exported by `@contentstack/studio-registry` (a dependency of `@contentstack/studio-react`, not re-exported by it in 2.0.1), so add it as a direct dev dependency at the same version before importing it, and keep the check out of production builds:
+
+```tsx
+// Dev-only, in the /canvas page (or a component it renders), after the registration module has run.
+import { useEffect } from "react";
+import { getComponentRegistryInstance } from "@contentstack/studio-registry";
+
+export function PaletteCheck() {
+ useEffect(() => {
+ if (process.env.NODE_ENV !== "development") return;
+ const mine = getComponentRegistryInstance().getComponents({ registry: "user-defined" });
+ console.table(mine.map((c) => ({ type: c.type, name: c.displayName, thumbnail: Boolean(c.thumbnailUrl) })));
+ }, []);
+ return null;
+}
+```
+
+Load `/canvas` in a browser (or Playwright) and read the table from the console: every registration should be listed, and `thumbnail` should be `true` on every row. A type missing from the table never registered on the client, whatever the source says.
+
```bash
# 4 — sections missing ui_preview, over the CMA (never the CDA — it hides unpublished sections)
#
@@ -128,12 +147,14 @@ print('ui_preview field:', 'present' if 'ui_preview' in f else 'MISSING - writes
## Where this runs in the build
-Not a final sweep. Items 1, 5 and 6 are **preflight**. They gate everything after them. Items 2, 3 and 4 are **per-piece**, done as each piece is created, never batched to the end:
+Not a final sweep. Items 1, 5, 6 and 9 are **preflight**. They gate everything after them. Items 2, 3 and 4 are **per-piece**, done as each piece is created, never batched to the end:
```
preflight : 1 appendTags · 5 Live Preview + token · 6 /canvas route · 9 builder mode
per component : 2 CSLP tags · 3 thumbnailUrl ← at registration time, same commit
per section : 4 ui_preview ← immediately after the composition write
+per template : 8 Sections counted · 10 new entry editable
+every write : 7 publish the Section or Template ← after every tree edit too
handover : re-run the verification block; report counts, not adjectives
```
@@ -154,7 +175,7 @@ Print them under a heading like **"What you'll be able to do yourself"**: the ap
## Acceptance
-- [ ] All six rows verified by the commands above (or the DOM check for #2), and the **counts printed**, never "thumbnails added" without the number.
+- [ ] All ten rows verified by the commands above (the DOM check for #2, the palette check for #3), and the **counts printed**, never "thumbnails added" without the number.
- [ ] `registerComponent` count **equals** `thumbnailUrl` count in every registration file.
- [ ] Zero Section compositions with an empty `ui_preview`, audited over the **CMA**, including sections created as a side effect (clones, rescopes, batch builds). The audit filtered on **`place_composition_as`**, paged past `limit=100`, and printed how many entries it actually inspected: `0 sections` is a broken audit, not a clean one.
- [ ] A rendered atom's root shows `data-cslp` in the DOM **and** its bound fields show their own.
@@ -166,7 +187,7 @@ Print them under a heading like **"What you'll be able to do yourself"**: the ap
| Pitfall | Why it bites | Fix |
|---|---|---|
| Editing a composition's tree and not republishing | The edit creates an unpublished version, delivery keeps serving the old one. The CMA, the canvas and the resolved `url` all look right, so the investigation goes to the template instead of the sections | Publish after every write **and** every edit, assert `publish_details` is non-empty |
-| Treating these as polish to do later | All six are invisible to the developer loop, so "later" never arrives: the user discovers them when they open Studio and can't work | Preflight items gate the build, per-piece items ship in the same step that creates the piece |
+| Treating these as polish to do later | All ten are invisible to the developer loop, so "later" never arrives: the user discovers them when they open Studio and can't work | Preflight items gate the build, per-piece items ship in the same step that creates the piece |
| Filtering compositions on a `type` field | There is no such field: the discriminator is `place_composition_as`. The audit matches nothing and reports success having inspected **zero** sections. Measured on a live project holding 94 | Filter on `place_composition_as == "section"` and print the inspected count, so an empty match is visible |
| Trusting a single page of results | `limit` is capped at 100 server-side, so a project with 141 compositions silently audits the first 100 | Page with `skip` until `count` is reached |
| Auditing `ui_preview` over the delivery API | The published list is short: one project read 86 compositions on the CDA vs **122** on the CMA, hiding 36 blank sections | Audit over the CMA |
@@ -177,7 +198,7 @@ Print them under a heading like **"What you'll be able to do yourself"**: the ap
## See also
-- [`plan-studio-architecture`](plan-studio-architecture.md): prints these six in the plan up front, so the user approves a build that includes them
+- [`plan-studio-architecture`](plan-studio-architecture.md): prints these ten in the plan up front, so the user approves a build that includes them
- [`byoc-end-to-end`](byoc-end-to-end.md): the execution macro, this checklist is its Phase 6 gate
- [`authenticate-cma`](authenticate-cma.md): how the audits above authenticate, OAuth-first, no pasted session token
- [`verify-setup`](verify-setup.md): proves the installation works, this proves the build is usable
diff --git a/skills/studio/configure-csr-vs-ssr.md b/skills/studio/configure-csr-vs-ssr.md
index 0811f3f..bb5b6f5 100644
--- a/skills/studio/configure-csr-vs-ssr.md
+++ b/skills/studio/configure-csr-vs-ssr.md
@@ -22,7 +22,7 @@ Pick your recipe from the framework recipes chapter and copy it verbatim:
- **Next.js App Router: RSC entry**: CI-tested on Next 14 + Next 16. Smallest possible client bundle.
- **Next.js App Router: client-component SSR**: main-entry path, simplest wiring, full SEO.
-Same three-call contract everywhere (`fetchCompositionData`, then ``, then `getSSRStyleTags` or ``), plus `getCompositionMetadata` for SEO. Sections below cover the render-strategy decision itself. The mechanics live in the recipes above.
+Same contract everywhere: `fetchCompositionData`, `` (`` on the RSC entry), then the server styles (`getSSRStyles` in React, `getSSRStyleTags` outside React, or `` on the RSC entry), plus `getCompositionMetadata` for SEO. Sections below cover the render-strategy decision itself. The mechanics live in the recipes above.
## Context
@@ -44,18 +44,18 @@ const specOptions = await sdk.fetchCompositionData({ url, searchQuery }, { local
## The render-pipeline rule (read this first)
-What determines **true SSR** (the composition DOM in the server HTML) is whether `` renders through a **`renderToString` pipeline** (Pages Router / Vite custom server / Remix: server React dispatcher present, built-ins execute server-side) OR through **React Server Components** (App Router: built-ins are `"use client"` and the SDK renderer detects them as `Symbol.for("react.client.reference")` and so emits `data-cs-defer-builtin` placeholders, deferring real render to client hydration). The data-fetch function (`fetchCompositionData` vs `useCompositionData`) is orthogonal and does **not** by itself produce SSR.
+What determines **true SSR** (the composition DOM in the server HTML) is whether `` renders through a **`renderToString` pipeline** (Pages Router / Vite custom server / Remix) or, on App Router, through a **`"use client"` wrapper**, which Next also renders to HTML on the server. The RSC entry (`@contentstack/studio-react/rsc`) is the other App Router path: its `` renders in a Server Component. `@contentstack/studio-react` ships no `"use client"` directive and its renderer calls hooks, so a Server Component cannot render it directly. The data-fetch function (`fetchCompositionData` vs `useCompositionData`) is orthogonal and does **not** by itself produce SSR.
-**Net:** every mainstream host, including Next App Router (main entry and RSC entry), Pages Router, Remix, Astro, Gatsby, and plain Node, renders the composition into the server HTML via the SDK's three-call contract. See [`install-studio`](install-studio.md) for the per-host wiring. The historical `data-cs-defer-builtin` placeholder path was removed in a later SDK release.
+**Net:** every mainstream host, including Next App Router (main entry and RSC entry), Pages Router, Remix, Astro, Gatsby, and plain Node, renders the composition into the server HTML via the SDK's contract. See [`install-studio`](install-studio.md) for the per-host wiring. The main entry's `` can't render straight from a Server Component (its renderer calls hooks): put it behind a `"use client"` wrapper, or render `` from `@contentstack/studio-react/rsc`.
## Decision table
| Situation | Path | True SSR? (composition DOM in server HTML) |
| --- | --- | --- |
| Vite / React SPA, no SEO | **CSR**: `useCompositionData` | No (client only) |
-| **Next.js Pages Router** | **SSR**: `sdk.fetchCompositionData` inside `getServerSideProps` | **Yes**: `renderToString` |
-| **Next.js App Router (main entry)** | `sdk.fetchCompositionData` in a Server Component, then a client wrapper renders `` | **Yes**: Next server-renders the client component's initial output |
-| **Next.js App Router (RSC entry)** | `sdk.fetchCompositionData` in a Server Component, then `` plus `` from `@contentstack/studio-react/rsc` | **Yes**: ~142B client bundle for structural content. Custom components register as client islands automatically |
+| **Next.js Pages Router** | **SSR**: `sdk.fetchCompositionData` and `getSSRStyles` inside `getServerSideProps` | **Yes**: `renderToString` |
+| **Next.js App Router (main entry)** | `sdk.fetchCompositionData` and `getSSRStyles` in a Server Component, then a client wrapper renders `` | **Yes**: Next server-renders the client component's initial output |
+| **Next.js App Router (RSC entry)** | `sdk.fetchCompositionData` in a Server Component, then `` plus `` from `@contentstack/studio-react/rsc` | **Yes**: ~142B client bundle for structural content. Custom components register as client islands automatically |
| Vite custom-server SSR | `renderToString` + `hydrateRoot` + `sdk.fetchCompositionData` | **Yes** |
| Remix | `sdk.fetchCompositionData` inside the `loader` | **Yes**: `renderToString` |
| Static / rarely changing | **SSG/ISR**: `getStaticProps` + `revalidate` | **Yes** (at build/revalidate. Via `renderToString`) |
@@ -71,7 +71,7 @@ On a miss, `sdk.fetchCompositionData` **throws**. It does not resolve with `hasS
What it throws is a `ComposableStudioError`. **Do not branch on a list of names.** Call `isCompositionNotFound(err)`, exported for exactly this decision:
```ts
-import { isCompositionNotFound } from "@contentstack/studio-client";
+import { isCompositionNotFound } from "@contentstack/studio-react";
```
It answers *did we establish that nothing is here?* and is the only thing that should decide a 404. A list of names has to be kept in step with the SDK, and the cost of drift is one-way: mistake a setup failure for a missing page and a wrong api key or a delivery outage returns **404 on pages that exist**, de-indexing a working site.
@@ -79,20 +79,35 @@ It answers *did we establish that nothing is here?* and is the only thing that s
Also on the error: `err.code` (the precise case, for logs and support) and `err.errorClass`, of which only `NOT_FOUND` is a 404. `err.causes` carries `{ cause, fix }` pairs for that failure, most likely first: log it in dev, it is the fastest diagnosis available.
```ts
-// lib/resolve-composition.ts
-import { isCompositionNotFound } from "@contentstack/studio-client";
-
-export async function resolveComposition(sdk, args, opts = {}) {
+// lib/resolve-composition.ts — server-only
+import { isCompositionNotFound, type studioSdk } from "@contentstack/studio-react";
+// The Delivery stack your Studio sdk was initialized with (`stackSdk`). On the
+// minimal-add branch that module is lib/studio.ts.
+import { stack } from "@/lib/contentstack";
+
+type StudioSdk = ReturnType;
+type FetchArgs = Parameters;
+
+export async function resolveComposition(sdk: StudioSdk, args: FetchArgs[0], opts: FetchArgs[1] = {}) {
+ // The stack is shared by every request this server handles. The SDK SETS a
+ // request's preview hash on it but never clears it, so without this line the
+ // next visitor is served the last editor's drafts. Clearing first is always
+ // safe: when this request carries a hash, the SDK sets it again itself.
+ stack.livePreviewQuery({ live_preview: "" });
try {
const specOptions = await sdk.fetchCompositionData(args, opts);
- return { specOptions, notFound: false }; // resolved ⇒ renderable
+ return { specOptions, notFound: false as const }; // resolved ⇒ renderable
} catch (err) {
- if (isCompositionNotFound(err)) return { specOptions: null, notFound: true };
+ if (isCompositionNotFound(err)) return { specOptions: null, notFound: true as const };
throw err; // real failure — must stay a 500
}
}
```
+**Every call on one request must forward the same `searchQuery`.** A layout or an embedded composition that passes `""` while the page passes the preview hash clears the hash the page's fetch just set, since both run concurrently on the same stack, and part of the page renders published content inside the editor ([`troubleshoot-canvas`](troubleshoot-canvas.md) § Live Preview state resets mid-request). Thread the page's query string down to them.
+
+The `as const` flags let TypeScript narrow `specOptions` to non-null after `if (miss) notFound()`. [`install-studio`](install-studio.md) emits this file next to the SDK init, so every route that uses it gets the hash reset without thinking about it (§ A server with a preview token can serve drafts to visitors).
+
**Do not blanket-catch.** A bare `try { … } catch { notFound() }` turns every CDA outage, expired delivery token, and network blip into a silent 404. The site looks empty instead of broken, and monitoring sees nothing. Ask the predicate. Rethrow the rest.
**`content_type_url_pattern` moves the failure, it doesn't remove it.** With `url_metadata.url_source: "content_type_url_pattern"`, the composition's pattern matches a whole family of URLs (`/blog/{{entry.title}}` matches any `/blog/*`), so a bad slug sails past **pattern match** and dies at **entry lookup** — a different code, `URL_MATCHED_NO_ENTRY` rather than `NO_COMPOSITION_FOR_URL`. Both are `NOT_FOUND`, so the predicate covers both without you listing either; this is the case that used to catch people out when they hand-rolled the list. See [`troubleshoot-composition-resolution`](troubleshoot-composition-resolution.md) § Runtime error taxonomy.
@@ -105,14 +120,7 @@ Every fetch snippet below is written against `resolveComposition`. If you copy a
The Delivery stack is shared by every request on a server process. `fetchCompositionData` sets a request's preview hash on it and never clears it when the next request has none (by design), so later visitor requests go to the preview host with the last hash. Measured: one request with `?live_preview=`, then the next plain request went to `rest-preview.contentstack.com`. Mechanism: [`troubleshoot-canvas`](troubleshoot-canvas.md) § shared stack instance hazard.
-A deployment with **no** preview token is safe ([`deploy-studio-site`](deploy-studio-site.md)). On a preview deployment, clear the hash when a request has none:
-
-```ts
-// `stack` is the Delivery stack your Studio sdk was initialized with
-// (contentstack.stack({...}) in lib/studio.server.ts); `searchQuery` is the raw request query string.
-const q = new URLSearchParams(searchQuery);
-if (!q.get("hash") && !q.get("live_preview")) stack.livePreviewQuery({ live_preview: "" });
-```
+A deployment with **no** preview token is safe ([`deploy-studio-site`](deploy-studio-site.md)). Everywhere else, [`resolveComposition`](#resolve-composition-helper) clears the hash before every fetch: `stack.livePreviewQuery({ live_preview: "" })`, after which the SDK re-applies the request's own `hash` / `live_preview` param when there is one (it only ever sets a hash that is present). A route that calls `sdk.fetchCompositionData` or `sdk.fetchComposition` directly needs that same line in front of the call.
Measured: the plain request then stayed on the CDN. Requests in flight at the same time still share the stack, so on a preview server under concurrent load, create the stack and the Studio sdk **per request** instead of sharing one module-level instance:
@@ -132,6 +140,20 @@ export function createStudioSdk() {
}
```
+With a per-request stack nothing carries over between requests, so the reset in `resolveComposition` has nothing to clear on it. Keep the helper for its 404 classification.
+
+
+
+## Static generation and caching on a Studio route (Next.js)
+
+Three things change when a route starts rendering Studio compositions. None of them errors; they show up as a slower site or stale pages.
+
+- **Reading the request query makes the render dynamic.** Studio needs the query string (editor mode, preview hash, locale, variant) on server-rendered routes, and awaiting `searchParams` in an App Router page (or using `getServerSideProps` in Pages Router) opts that render out of static prerendering. A greenfield catch-all is dynamic, and that is the price of in-place editing. On an app whose catch-all also serves its own pages, read `searchParams` only inside the Studio branch ([`setup-template-preview-routes`](setup-template-preview-routes.md) § Existing catch-all), so the app's own pages keep rendering the way they did.
+- **Studio's fetches bypass Next's data cache.** The Delivery SDK uses axios, not `fetch`, so `fetch`-level `revalidate` and cache tags never apply to composition or entry reads. Cache at the route level (`export const revalidate = ` on a route that doesn't read `searchParams`, or the host's CDN). Don't expect a tag-based `revalidateTag` to touch Studio content.
+- **The app's publish webhook doesn't cover compositions.** Compositions are entries of the compositions content type, and an author publishing a page publishes those entries, often without touching any entry the existing webhook listens for. If the app revalidates on publish, add the compositions content type to the webhook and revalidate the composition's `url` (or the whole catch-all) when one is published. A composition with a URL pattern (`/blog/{{entry.url}}`) also changes every page its template serves, so revalidate that path prefix.
+
+Preview and editor requests must never be served from a cache: they carry `hash` / `live_preview` / `cs-composable-studio` params, and a cached copy shows the wrong draft or no editor at all. Keep any CDN rule keyed on the full query string, or bypass the cache when one of those params is present.
+
## Task
1. **Pick the path** from the decision table using `framework`. Run only the matching block below. Skip the others.
@@ -139,8 +161,7 @@ export function createStudioSdk() {
2. **CSR (Vite / SPA, also the universal fallback on any framework, including App Router).**
```tsx
"use client";
- import { useCompositionData, StudioComponent } from "@contentstack/studio-react";
- import { isCompositionNotFound } from "@contentstack/studio-client";
+ import { useCompositionData, StudioComponent, isCompositionNotFound } from "@contentstack/studio-react";
// Loading, ErrorState and NotFound are your app's own components.
import { Loading, ErrorState, NotFound } from "@/components/states";
@@ -164,9 +185,10 @@ export function createStudioSdk() {
**Live Preview wiring is separate.** If this route needs to reflect author edits in real time, add the `onEntryChange` bridge from `install-live-preview` (step 7). CSR pages call the hook's `refetchData` inside the loop-safe callback — it re-fetches just the bound data, which is all a live edit changes.
-3. **App Router (main entry): SSR through a client wrapper.** A Server Component fetches, a `"use client"` wrapper renders, and Next puts that output in the initial HTML (verified with `curl` in the App Router recipe). Initialize the sdk in a server-only module (no `"use client"`). Register components in a `"use client"` module the wrapper imports. Rendering `` directly in a Server Component makes built-ins `react.client.reference` proxies: the SDK emits `data-cs-defer-builtin` placeholders, filled only on hydration.
+3. **App Router: SSR through a client wrapper.** A Server Component fetches and computes the styles, a `"use client"` wrapper renders, and Next puts that output in the initial HTML (verified with `curl` in the App Router recipe). Initialize the sdk in a server-only module (no `"use client"`). Register components in a `"use client"` module the wrapper imports. Rendering `` directly in a Server Component fails with a Next.js client-component error: the renderer calls hooks.
```tsx
// app/[[...slug]]/page.tsx — NO "use client"
+ import { getSSRStyles } from "@contentstack/studio-react";
import { sdk } from "@/lib/studio.server";
import { resolveComposition } from "@/lib/resolve-composition";
import { ClientRender } from "./ClientRender";
@@ -188,7 +210,13 @@ export function createStudioSdk() {
// 404s every freeform page however it is published. `miss` already carries
// the only not-found answer there is.
if (miss) notFound();
- return ;
+ // getSSRStyles here, never in ClientRender: hydration mismatch on the >);
+ return ;
}
```
- **Next.js Pages Router** (`pages/.tsx`), same SSR-mismatch reason, wrap in `dynamic({ ssr: false })`:
+ **Next.js Pages Router** (`pages/.tsx`), same body without the directive:
```tsx
- import dynamic from "next/dynamic";
- const StudioCanvas = dynamic(
- () => import("@contentstack/studio-react").then((m) => m.StudioCanvas),
- { ssr: false },
- );
+ import { StudioCanvas } from "@contentstack/studio-react";
export default function CanvasRoute() {
return ;
}
```
+ No `dynamic({ ssr: false })` needed on studio-react 2.x: `StudioCanvas` is a client component that renders `null` on the server and on the first client render, and builds the canvas only after mount, so there is nothing to mismatch. Older snippets that wrap it in `dynamic()` still work; they're just unnecessary.
+
**Vite + React Router** (`src/routes/CanvasRoute.tsx` + router config update), Vite is SPA-only, so a plain import is safe:
```tsx
import { StudioCanvas } from "@contentstack/studio-react";
@@ -112,7 +150,7 @@ Only once 1 to 3 hold, continue.
}
```
- **Every framework: render `` beside ``.** In studio-react 1.9.0 `StudioCanvas` writes no design-token `:root` variables, so token styles (padding, max-width, colors) resolve to nothing on the canvas.
+ **No token `` to a client canvas page: the server and the browser hold different token registries, so the string differs between them and React reports a hydration error on every load. Only on studio-react 1.9.x, where `StudioCanvas` wrote no token variables, add them **after mount**: `const [css, setCss] = useState(""); useEffect(() => setCss(getSSRStyles(null)), []);`, then render `{css ? : null}`.
Convert `` to the framework's expected filename (e.g. `/canvas` becomes `canvas/page.tsx` for App Router, or `canvas.tsx` for Pages Router).
@@ -158,7 +196,6 @@ Only once 1 to 3 hold, continue.
const canvasHeaders = [
// Studio runs on *.contentstack.com (all regions). Add your own origin if you embed the canvas elsewhere.
{ key: 'Content-Security-Policy', value: "frame-ancestors 'self' https://*.contentstack.com;" },
- { key: 'Access-Control-Allow-Origin', value: '*' },
];
return [
{ source: '/canvas', headers: canvasHeaders },
@@ -168,7 +205,7 @@ Only once 1 to 3 hold, continue.
};
```
- The Vite blocks above are for local use only and allow framing by Studio's origin, nothing wider. Don't add `frame-ancestors *` or `Access-Control-Allow-Origin: *`, and don't ship a global framing policy to production: scope it to the canvas route.
+ The Vite blocks above are for local use only and allow framing by Studio's origin, nothing wider. Don't add `frame-ancestors *` or `Access-Control-Allow-Origin: *`: framing is governed by `frame-ancestors` alone, and CORS headers on the page play no part in it. Don't ship a global framing policy to production either: scope it to the canvas route.
**Why it matters:** the `frame-ancestors` CSP header tells the browser Studio's origin may embed the page; without it, Studio's iframe stays blank with no console error, a particularly hard failure to diagnose. If you reach the server through a non-localhost hostname (e.g. a tunnel) and Vite returns "host not allowed" (403), list that hostname in `allowedHosts`; never set it to `true`.
@@ -187,10 +224,11 @@ Only once 1 to 3 hold, continue.
- [ ] BEFORE creating the route: confirmed the targeted environment has a non-empty per-locale URL equal to the dev origin, AND the Studio project targets that environment
- [ ] Canvas route file exists in the right location for the detected framework
-- [ ] The file mounts `` and the token `` on the canvas page: it now only causes a hydration error. `grep -rn 'getSSRStyles(null)' src app` | [`setup-section-preview`](setup-section-preview.md) |
+| Internal packages | `studio-client` 3.2, `studio-registry` 1.10 | `studio-client` 4.1, `studio-registry` 2.0 | Nothing, unless `package.json` pins one of them directly: then bump it in the same change (step 3) or you get two registries | Step 3 |
+
+### Also check: rules older guides and code got wrong
+
+These behave the same on 1.9.0 and 2.0.1, so they are not 2.0 changes. Code copied from older versions of these guides often breaks them, and an upgrade is a good time to fix it. TypeScript reports the first four.
+
+| Rule | Wrong | Right | Find it | Details |
+|---|---|---|---|---|
+| `object` props need `properties` | `{ type: "object" }` | `{ type: "object", properties: { … } }` | `grep -rn 'type: "object"' src` | [`register-component`](register-component.md) step 3 |
+| `array` props need `items` | `{ type: "array" }` | `{ type: "array", items: { type: "any" } }` (or the per-item prop) | `grep -rn 'type: "array"' src` | [`register-component`](register-component.md) step 3 |
+| Lazy components register through `registerLazyComponent` | `registerComponent({ …, component: () => import("./X") })` (runs, but doesn't type-check) | `registerLazyComponent(config, () => import("./X").then((m) => m.X))`; the loader resolves to the component, not the module | `grep -rn 'component: () => import' src` | [`register-component`](register-component.md) § 5b |
+| The hook takes no `searchQuery` | `useCompositionData({ url, searchQuery })` | `useCompositionData({ url })`; it reads `window.location` itself | `grep -rn 'useCompositionData(' src app` | [`setup-template-preview-routes`](setup-template-preview-routes.md) |
+| `styles` is an object of style groups | `styles: [ … ]` | `styles: { default: …, : … }`; `default` arrives as `className` | `grep -rn 'styles: \[' src` | [`register-component`](register-component.md) § 5c |
+| `extendQuery` is deprecated | relied on silently | Still honoured, warns in development; no documented replacement for its `only` half | `grep -rn 'extendQuery' src app` | [`register-component-prop-contracts`](register-component-prop-contracts.md) |
+| `refetchData` needs a connected composition | `refetchData` on every page | Fall back to `refetchSpec` on freeform compositions and before the first load | `grep -rn 'refetchData' src app` | [`csr-and-ssr-requirements`](csr-and-ssr-requirements.md) |
+
+**After the bump:** restart the dev server (a running server keeps the old registrations), run the type check, then the smoke tests in step 4. Check the Components panel on the canvas: a registration that fails at runtime drops out of the palette without an error on the page.
+
## Knowing what actually changed
-Each package publishes a CHANGELOG derived from Conventional Commits. Read the CHANGELOG for every package in your delta before upgrading.
+There is no public changelog for these packages (step 2). Read the compiler output and § Upgrading from 1.x to 2.0. Within a major, compare the installed `.d.ts` files before and after (`diff -r` two installs of `node_modules/@contentstack/studio-react/dist`) when you need more than the type check gives you.
-A non-exhaustive list of changes that historically required consumer-side action (read each from its CHANGELOG before claiming it applies to your delta):
+A non-exhaustive list of older changes that required consumer-side action (confirm each applies to your delta before claiming it does):
- **Registry singleton moved to `globalThis`** (`studio-registry` / `studio-react` coordinated bump): required for RSC server-side fetch to see registered components. If you're upgrading FROM a pre-singleton version TO a post-singleton version, server-side `fetchSpec` calls that used to return "component not registered" will now resolve. No consumer code change beyond the upgrade.
- **Basics moved behind a `"use client"` isolation boundary** (`studio-react`): fixes "Attempted to call Page() from the server" in Next App Router. No consumer code change required. The renderer pulls in the client-only basics automatically.
- **Tolerant `useData` fallback** (`studio-react-components`): replaces a throw with an empty-data fallback plus a dev-only warning. SSR pass of basics no longer crashes outside a `DataCtxProvider`. Consumer-side: dev console may show one new warning, production unchanged.
- **`fetchCompositionData` requires `searchQuery` on the server**: see [`configure-csr-vs-ssr.md`](configure-csr-vs-ssr.md). Required for locale/variant/preview to flow from Studio's iframe params. Pre-existing as an integration step.
-Do NOT extrapolate from this list. **Only the actual CHANGELOG entries for your delta** tell you which of these apply.
+Do NOT extrapolate from this list. Confirm each against the installed types or a smoke test before claiming it applies.
## What this skill is NOT
- Not a guide to first-time installation (use [`install-studio`](install-studio.md)).
- Not a guide to SDK-internal development.
-- Not a substitute for reading the CHANGELOG. Always read the actual CHANGELOG entries for your version delta.
+- Not a substitute for the type check and smoke test. Run both for every version delta.
- Not a guide to upgrading the Contentstack Delivery SDK (`@contentstack/delivery-sdk`) or Live Preview Utils (`@contentstack/live-preview-utils`): those are separate packages with their own CHANGELOGs, only loosely coupled to the Studio SDK.
diff --git a/skills/studio/use-compositions-api.md b/skills/studio/use-compositions-api.md
index e71e936..7d7c5c0 100644
--- a/skills/studio/use-compositions-api.md
+++ b/skills/studio/use-compositions-api.md
@@ -4,9 +4,9 @@ name: use-compositions-api
## When to use
-Create, read, list, update, or delete Studio compositions through the **managed Composable Studio API service**, the REST layer that validates the layout, compresses the `ui` spec, and forwards the caller's identity, so you don't hand-encode entries against the raw CMA.
+Create, read, list, update, or delete Studio compositions through the **managed Studio API service**, the REST layer that validates the layout, compresses the `ui` spec, and forwards the caller's identity, so you don't hand-encode entries against the raw CMA.
-Use when the project talks to the Composable Studio API service (`/v1/projects/:projectUid/compositions`) and you want guardrails: server-side Studio validation, automatic `ui` compression, `composable_uid` backfill, a referenced-guard on delete, and list/references endpoints. Phrases: "compositions REST API", "Studio API service", "create composition endpoint", "list compositions", "composition references API", "managed composition API". Do NOT use for: raw-CMA / seed-pipeline / migration authoring where you hold a management token and encode the `ui` yourself. That's `author-composition-via-api`. Do NOT use for interactive UI authoring. That's `build-section` / `build-connected-template`. The `ui`-tree shapes themselves are NOT redefined here. They live in `author-composition-via-api`.
+Use when the project talks to the Studio API service (`/v1/projects/:projectUid/compositions`) and you want guardrails: server-side Studio validation, automatic `ui` compression, `composable_uid` backfill, a referenced-guard on delete, and list/references endpoints. Phrases: "compositions REST API", "Studio API service", "create composition endpoint", "list compositions", "composition references API", "managed composition API". Do NOT use for: raw-CMA / seed-pipeline / migration authoring where you hold a management token and encode the `ui` yourself. That's `author-composition-via-api`. Do NOT use for interactive UI authoring. That's `build-section` / `build-connected-template`. The `ui`-tree shapes themselves are NOT redefined here. They live in `author-composition-via-api`.
> **Auth preflight: settle the credential before the first API call.** The service accepts OAuth Bearer. Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session (**ask the user before reading or refreshing it**: refreshing rotates the refresh token; see that skill for where it lives). **Don't ask for a session `authtoken` up front**: it is the last rung, considered only after a freshly refreshed OAuth token has been observed to fail, and only after explaining the risk. If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION= npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate.
@@ -14,7 +14,7 @@ Use when the project talks to the Composable Studio API service (`/v1/projects/:
## Context
-The **Composable Studio API service** is a REST wrapper over the CMS. Instead of building a composition entry by hand, you `POST` the layout and the service does the mechanical work:
+The **Studio API service** is a REST wrapper over the CMS. Instead of building a composition entry by hand, you `POST` the layout and the service does the mechanical work:
- **You send the `ui` tree uncompressed** (a JSON object). The service compresses it to `zlib:`. You never touch zlib.
- **The service validates** the tree against Studio's structural rules and returns all problems at once, field-anchored.
@@ -43,8 +43,6 @@ All routes are prefixed with `/v1` and nested under the project. Base URL per re
| Create a section | `POST …/compositions/sections` |
| List / query | `GET …/compositions` |
| Fetch one | `GET …/compositions/{uid}` |
-| Fetch, assert template | `GET …/compositions/{uid}/template` |
-| Fetch, assert section | `GET …/compositions/{uid}/section` |
| List references | `GET …/compositions/{uid}/references` |
| Update (partial merge) | `PUT …/compositions/{uid}` |
| Delete (guarded) | `DELETE …/compositions/{uid}` |
@@ -85,17 +83,34 @@ curl -X POST "$STUDIO_API/v1/projects/$PROJECT/compositions" \
"place_composition_as": "page",
"url": "/blog/{{entry.slug}}",
"connected_content_type": "blog_post",
- "ui": { "uid": "root", "type": "page", "props": {}, "slots": {}, "metadata": {} },
+ "ui": { "uid": "root", "type": "page", "attrs": {}, "metadata": {},
+ "props": {}, "slots": {}, "styles": {} },
"data_sources": []
}'
```
+Body shapes (`ui` as an object, `data_sources` as an array, no `entry` wrapper) differ from the raw CMA: [`author-composition-entry`](author-composition-entry.md#wire-shapes) § Wire shapes is the one definition. Every node carries all seven keys (`uid`, `type`, `attrs`, `metadata`, `props`, `slots`, `styles`).
+
On success (`201`) the response is `{ notice, composition, warnings? }`. The returned `composition.ui` is the **compressed** string as stored. `composable_uid` equals the entry uid unless you supplied one. Non-blocking findings ride along in `warnings[]`.
### Step 3: Handle validation
Blocking problems return **`422` `composition_invalid`** with every failing path at once: `errors: { "": [""] }`. Fix each path and resend. Warnings don't block, surface them but proceed.
+A common `422`: `Empty slots crash the renderer.` on a `slots` entry holding `[]`, most often a `section-slot` node written as `{ "": [] }`. Write `"slots": {}` ([`author-composition-sections`](author-composition-sections.md) § `section-slot` node shape).
+
+
+
+#### Expected validator warnings {#expected-validator-warnings}
+
+Three warnings show up on correct compositions. Report them as expected, don't "fix" them:
+
+| Warning | Why it appears on a correct composition | Act only if |
+|---|---|---|
+| `unknown_type` on a registered component's node | Components are registered in the browser (`registerComponent` in the app), so the service never sees the registry. It flags every non-built-in `type` | The `type` string differs from the one in the app's `registerComponent` call. Check that by hand |
+| `linked_sections_required` on a template | Raised even when every placed section is already in `linked_sections` | A `section-composition` node's `metadata.compositionUID` is missing from `linked_sections`. Compare the two sets |
+| "items won't render" on the item Box under a Repeater | The [canonical list recipe](author-composition-repeater-recipes.md#canonical-list-recipe) puts a Box between the Repeater and its Condition Blocks so Visual Editor gets item tags | The Box holds no Condition Block on a reference or modular-block list |
+
### Step 4: Read, list, reference
- **Fetch one:** `GET …/{uid}` returns the entry with `ui` compressed by default. Add `?decompression=true` to get the node tree back (and `data_sources` parsed).
@@ -139,7 +154,8 @@ A composition created through this API is not finished when the write returns `2
| Pitfall | Why it bites | Fix |
|---|---|---|
| Sending `ui` pre-compressed | The service compresses for you. A `zlib:` string is treated as the tree and fails validation | Send the **uncompressed node object** |
-| `data_sources` as a JSON string | The service stringifies it. A string double-encodes | Send a plain **array** |
+| `data_sources` as a JSON string | The service stringifies it. A string answers `400` | Send a plain **array** ([Wire shapes](author-composition-entry.md#wire-shapes)) |
+| Treating `unknown_type` or `linked_sections_required` as errors | They fire on correct compositions | See § Expected validator warnings |
| Blank `composable_uid` | A whitespace-only value is rejected, not backfilled | Omit it, or send a real slug. A blank value gives `422` |
| Duplicate `composable_uid` in another locale | Uniqueness is content-type-wide, not per-locale | Expect `409` at create. Pick a unique slug |
| Treating `422 stack_not_found` as "missing" | It's the **access-denied** code (Studio convention, not 403) | Check the caller's rights on the project's stack |
diff --git a/skills/studio/use-condition-block.md b/skills/studio/use-condition-block.md
index 3d7475f..b794444 100644
--- a/skills/studio/use-condition-block.md
+++ b/skills/studio/use-condition-block.md
@@ -52,7 +52,7 @@ If `repeaterSource` is a Reference and the user gives only one UID, still create
This skill succeeds only when ALL of the following are true. If any fails, do not claim success: surface the failure and stop.
-- [ ] In Layers the tree reads `Repeater > [Condition Block × N]`: each Condition Block is a direct child of the Repeater, or of the one Box that is the Repeater's only child (for Visual Editor's item tags: [`make-visual-editor-editable`](make-visual-editor-editable.md) § 2), never wrapping it.
+- [ ] In Layers the tree reads `Repeater > [Condition Block × N]`: each Condition Block is a direct child of the Repeater, or of the one Box that is the Repeater's only child (for Visual Editor's item tags: [`make-visual-editor-editable`](make-visual-editor-editable.md) § 2), never wrapping it. See the [canonical list recipe](author-composition-repeater-recipes.md#canonical-list-recipe).
- [ ] There is exactly one Condition Block per UID in `branches`. UIDs not in `branches` do not have a Condition Block.
- [ ] Each Condition Block's `condition.type` metadata is set to `modular_block` (Modular Block source) or `reference` (Reference source), matched to a specific block / content-type UID, never to a shared field name like `title`.
- [ ] Inside each Condition Block, the type-specific component's props are bound via `repeater.` paths. The Data Picker shows the typed fields of that Condition Block's schema, not a generic union.
diff --git a/skills/studio/use-repeater.md b/skills/studio/use-repeater.md
index da81dba..3745d0e 100644
--- a/skills/studio/use-repeater.md
+++ b/skills/studio/use-repeater.md
@@ -20,7 +20,7 @@ A Repeater is a Smart Container that renders its child subtree once per item of
**The scope rule, in user terms:** clicking inside a Repeater switches the Data Picker roots. Outside the Repeater = the Section's linked-schema fields. **Inside the Repeater = the iteration item's fields, labeled "Repeater Data".** Bind from whichever root the picker shows. The location determines the shape, not your choice.
-When the iteration items are heterogeneous (Modular Block lists, or References [single-CT and multi-CT]), the immediate child of the Repeater must be a Condition Block. Studio surfaces an inline hint when you bind a child directly and offers to wrap it for you. For Visual Editor's add / move / delete controls, then put those Condition Blocks inside one Box that is the Repeater's only child: [`make-visual-editor-editable`](make-visual-editor-editable.md) § 2.
+When the iteration items are heterogeneous (Modular Block lists, or References [single-CT and multi-CT]), the Repeater's only child is an item Box, and the Condition Blocks sit inside that Box. Studio's inline hint offers to wrap the bound child directly in a Condition Block; that puts it in the wrong place, so move it inside the item Box. Follow the [canonical list recipe](author-composition-repeater-recipes.md#canonical-list-recipe).
## What goes into a repeating Slot: a Section, not a raw component
@@ -39,7 +39,7 @@ When the Repeater contains a Section Slot (the common "list of cards" pattern),
- For a Pinned Query: pick under **Pinned Queries**.
- The picked node must be a list/array. The picker disables scalar leaves for this slot.
-5. **Wrap iteration in a Condition Block when required.** If `itemKind` is `modular-block` or `reference` (single-CT or multi-CT), the immediate child of the Repeater must be a Condition Block. The cleanest path: drop the `iterationComponent` directly inside the Repeater first. When you bind any prop on it, Studio shows an inline hint that this source needs a Condition Block. Accept the wrap prompt and Studio inserts the Condition Block around the component, scoping it to the active item type. For `group` and `array` of scalars, skip this step.
+5. **Wrap iteration in a Condition Block when required.** If `itemKind` is `modular-block` or `reference` (single-CT or multi-CT), put one item Box inside the Repeater as its only child, then a Condition Block inside that Box around the `iterationComponent`, scoped to the active item type ([canonical list recipe](author-composition-repeater-recipes.md#canonical-list-recipe)). If you accept Studio's wrap prompt instead, it inserts the Condition Block directly under the Repeater; move it inside the item Box. For `group` and `array` of scalars, skip this step.
6. **Drop the iteration content inside the Repeater.**
- **Preferred: drop a Section.** If the iteration content is non-trivial (a card with title + image + price + rating, etc.), build it as its own Section bound to the iterated item's CT and drop that Section into the Repeater (or into the Condition Block if step 5 applied, or into a Section Slot inside the Repeater. See the "What goes into a repeating Slot" pattern in Context above). The Section is pre-bound, nothing to wire per drop.
diff --git a/skills/studio/verify-setup.md b/skills/studio/verify-setup.md
index 16b31f8..bf65885 100644
--- a/skills/studio/verify-setup.md
+++ b/skills/studio/verify-setup.md
@@ -43,17 +43,30 @@ Use existing compositions for verification where possible. Any temporary authori
- Before testing any app-side wiring, ask the user to confirm ALL of:
- `Settings → Visual Experience → General → Live Preview → Enable Live Preview` checkbox is **checked + Save clicked**
- A `Default Preview Environment` is selected on the same page
- - The `Preview URL` sub-tab has a `Custom Preview URL` set (and is NOT showing the "Custom Preview URL Unavailable" lockout banner)
+ - If authors open Live Preview or Visual Editor **from an entry**, the `Preview URL` sub-tab has a `Custom Preview URL` set (and is NOT showing the "Custom Preview URL Unavailable" lockout banner). The Studio canvas alone doesn't use it: see [`enable-visual-experience`](enable-visual-experience.md#custom-preview-url)
- The Delivery Token in `.env` has a Preview Token generated (the **Create Preview Token** button on the token edit page must have been clicked at least once)
- **The environment your Studio project targets has a non-empty per-locale URL** for your locale, and for **every** other locale the app serves on a localized project (in the stack, open Settings, then Environments, then ``, and read the URL for ``). This is the canvas Base URL (origin). Empty = blank canvas with an error that points at the wrong layer. For local dev it must equal your dev origin (e.g. `http://localhost:5173`).
- If any of these is unconfirmed (or the user is unsure), STOP and route them through `enable-visual-experience` (Live Preview) or `setup-section-preview` (the env Base URL). Re-run this skill after that completes.
- Catches the most common failure: app wired, stack-level flag off, runtime silently broken. Skipping makes Layer 2 failures look like SDK bugs when they're actually web-app toggles.
-1. **Layer 1: Delivery SDK reachability.**
- - Run: `npx tsx -e "import('@contentstack/delivery-sdk').then(async cs => { const stack = cs.default.stack({ apiKey: process.env.CS_API_KEY, deliveryToken: process.env.CS_DELIVERY_TOKEN, environment: process.env.CS_ENVIRONMENT || 'preview' }); const r = await stack.contentType('home_page').entry().limit(1).find(); console.log('OK entries:', r.entries?.length ?? 0); })"`
- - Or, programmatically, call `stack.contentType('').entry().limit(1).find()` for a known CT in the app.
- - Pass: a query returns without throwing.
- - Fail modes: 401 means a wrong Delivery Token, 422 a wrong environment name, 404 a wrong CT UID, and ECONNREFUSED a wrong region host.
+1. **Layer 1: Delivery SDK reachability.** Probe with the credentials the app itself loads, without printing them and without assuming any content type.
+
+ **Reading the app's credentials.** Find the variable names in the app's Contentstack init module (the file `install-studio` wrote, or the app's own stack factory), and the env file that sets them (`.env.local`, `.env`). Load that file inside a subshell so the values never reach the transcript or your shell history, and print only status lines. Below, ``, ``, `` and `` are those names (for an `install-studio` app, `_CONTENTSTACK_API_KEY` and so on). If the values come from a secrets manager or runtime config instead of a file, ask the user to run the probe themselves and paste only the output.
+
+ ```bash
+ ( set -a; . ./.env.local; set +a # the app's own env file
+ H="" # cdn.contentstack.io, or the regional CDN host
+ CT=$(curl -s "https://$H/v3/content_types?limit=1" \
+ -H "api_key: ${}" -H "access_token: ${}" | jq -r '.content_types[0].uid // empty')
+ echo "first content type: ${CT:-none}"
+ curl -s -o /dev/null -w "entries probe: HTTP %{http_code}\n" \
+ "https://$H/v3/content_types/$CT/entries?environment=${}&limit=1" \
+ -H "api_key: ${}" -H "access_token: ${}" )
+ ```
+
+ - Any content type will do, so the probe takes the first one the stack returns rather than a guessed uid such as `home_page`. Record it: Layer 6 reuses it.
+ - Pass: a content type uid prints and the entries probe returns 200.
+ - Fail modes: `first content type: none` means the API key, Delivery Token or CDN host is wrong (rerun the first `curl` with `-w '%{http_code}'`: 401 is a credential, a connection error is the host); the entries probe then 404s on the empty uid, which says nothing more. A 422 on the entries probe means a wrong environment name.
2. **Layer 2: Live Preview iframe channel.**
- Open the host app at the Canvas URL in a browser.
@@ -66,6 +79,7 @@ Use existing compositions for verification where possible. Any temporary authori
- Click the "Preview" or eye icon, and the Visual Editor opens.
- Pass: the entry preview shows, fields have hover-edit highlights, clicking a field opens its edit panel.
- Fail modes: a blank iframe means the Canvas URL is wrong. Missing field highlights mean the `data-cslp` tags aren't being injected (the app isn't using `live-preview-utils` correctly). Stuck on "Initializing the editor…" means Live Preview is not in `mode: "builder"`.
+ - Page check (before opening the editor): the app's Live Preview init sets `cslp.appendTags: true` (or the components emit tags themselves), and a rendered page's HTML has `data-cslp` on each registered component's root and on its bound fields. No tags here means Layer 3 cannot pass.
- Lists: the rendered HTML has a `data-cslp="…."` tag and a `…..0` tag for every list, or Visual Editor cannot add, move or delete items ([`make-visual-editor-editable`](make-visual-editor-editable.md) § 2).
4. **Layer 4: Studio canvas.**
@@ -74,7 +88,7 @@ Use existing compositions for verification where possible. Any temporary authori
- Fail modes:
- A blank canvas means the canvas iframe address is wrong. Check both halves: the **Canvas URL** (path) on the Studio project must match the mounted route, AND the **targeted environment's per-locale Base URL** (origin) must be non-empty and reachable. An empty env Base URL is the most common cause and surfaces as a "no base URL"-style error pointing at the Studio project, not the environment.
- "Component Loading Error" means the section ref's `compositionUID` doesn't resolve (sections not preloaded or fetched from CDA)
- - "Authentication failed" means the Delivery or Preview token in `.env` is wrong or revoked. In the stack, open Settings, then Tokens, then the token itself to read its current values, paste those into `.env`, and restart the dev server
+ - `STACK_NOT_FOUND_ON_HOST` or 401/412: a key or token in `.env` is wrong. In the stack, open Settings, then Tokens, then the token itself to read its current values, paste those into `.env`, and restart the dev server
### Expected (not-a-failure) console messages when verifying
@@ -107,7 +121,7 @@ Use existing compositions for verification where possible. Any temporary authori
```bash
curl -I -sS --max-time 5 ${canvasAppUrl} | grep -iE 'content-security-policy|access-control-allow-origin'
```
- Pass: the canvas route's `Content-Security-Policy: frame-ancestors` permits the Studio origin (e.g. `frame-ancestors 'self' https://*.contentstack.com`). Wildcards (`frame-ancestors *`, `Access-Control-Allow-Origin: *`) are acceptable only on a local dev server, never on a deployed site. Fail: missing, or Studio's origin not permitted, so see `setup-local-https-canvas` / `setup-section-preview` for the Vite/Next config block. The Studio iframe will silently fail to embed without it.
+ Pass: the canvas route sends `Content-Security-Policy: frame-ancestors 'self' https://*.contentstack.com` (or names your Studio host). Framing is decided by `frame-ancestors` alone: an `Access-Control-Allow-Origin` header is not needed for it, and `frame-ancestors *` or `Access-Control-Allow-Origin: *` on the canvas route is a finding to remove, not a pass. Fail: missing, or Studio's origin not permitted, so see `setup-section-preview` step 5 for the Vite/Next config block. The Studio iframe will silently fail to embed without it.
- **5d. Arity ≥ 1 on every registered component**:
```bash
@@ -120,23 +134,24 @@ Use existing compositions for verification where possible. Any temporary authori
- If it fails, immediately apply the section-test diagnostic: open a section composition. If the section renders, the SDK is fine. The failure is the connected template's URL match. See `troubleshoot-composition-resolution` (entry `url` field needs a leading slash, and composition `url` must contain a `{{entry.x}}` pattern).
- Also verify the env base URL scheme: in `GET /v3/environments/`, `urls[].url` must be HTTPS if the canvas-app is HTTPS.
-6. **Layer 6: Preview Token authenticates with the CDA preview API** (behavior-based, project-agnostic, does NOT assume any specific env file path or env var name).
+6. **Layer 6: Preview Token authenticates with the CDA preview API** (behavior-based, project-agnostic: variable names come from the app's init module, not from a guessed convention).
The most common silent failure on a fresh setup: the **Preview Token** passed to the Delivery SDK / Live Preview SDK is invalid or empty, the CDA preview API returns 401 without CORS headers, and the browser surfaces a misleading "CORS error" instead of "401 Unauthorized." Hours of CORS chasing for a token paste. Verify the token authenticates **before** opening Studio.
- **The check is project-agnostic.** Don't grep `.env.local`, don't assume `VITE_*` / `NEXT_PUBLIC_*` / `REACT_APP_*` env var names: every framework / project layout differs. Ask the user for the API key and host and curl-verify directly. **Never ask the user to paste the Preview Token into chat, and never print it.** Have them export it in their own shell (`export PREVIEW_TOKEN=…`, from wherever the project loads it) so the curl below reads `${PREVIEW_TOKEN}`.
+ **The check is project-agnostic.** Don't assume `VITE_*` / `NEXT_PUBLIC_*` / `REACT_APP_*` names: read them from the app's init module, and load the env file in a subshell as in Layer 1. **Never ask the user to paste the Preview Token into chat, and never print it.** When the token isn't in a file (secrets manager, runtime config), ask the user to run the curl themselves and paste only the status line.
Inputs:
- `stackApiKey`: the stack's API key the canvas-app uses
- - `PREVIEW_TOKEN` (exported by the user in their shell, not pasted into chat): the Preview Token the canvas-app currently passes to `@contentstack/live-preview-utils` / Delivery SDK at runtime, wherever it's loaded from (env file, runtime config, secrets manager, fetched-from-API, hardcoded, doesn't matter)
+ - The Preview Token the canvas-app passes to the Delivery SDK's `live_preview` block at runtime, read from the app's env file inside the subshell (Layer 1), never echoed
- `previewHost`: region-dependent: `rest-preview.contentstack.com` (NA prod), `eu-rest-preview.contentstack.com` (EU prod), `azure-na-rest-preview.contentstack.com`, etc. (see `install-studio` § Regional host map). For a custom / private data center (hosts supplied by Contentstack), take the explicit host from [`analyze-project-fit`](analyze-project-fit.md) § 2b rather than asking again.
- - One ``, ``, `` from the project to query against: pick any content type the user knows has at least one entry in that environment.
+ - The content type Layer 1 found, the app's environment, and its default locale.
Run:
```bash
- curl -is "https://${previewHost}/v3/content_types/${CT}/entries?environment=${env}&locale=${locale}&limit=1" \
- -H "api_key: ${stackApiKey}" \
- -H "preview_token: ${PREVIEW_TOKEN}"
+ ( set -a; . ./.env.local; set +a
+ curl -s -w "\nHTTP %{http_code}\n" \
+ "https:///v3/content_types//entries?environment=${}&locale=&limit=1" \
+ -H "api_key: ${}" -H "preview_token: ${}" | tail -c 300 )
```
Outcomes:
@@ -154,7 +169,7 @@ Use existing compositions for verification where possible. Any temporary authori
Inputs:
- `previewUrl`: any visitor URL that resolves to a Connected Template that has sections placed on it (e.g. `https://localhost:3006/products/some-product`, `https://your-staging-site/blog/some-post`). The canvas-app must be running and reachable at this URL.
- Run (as a one-off Node script using the canvas-app's already-installed SDK):
+ Run as a one-off `.mjs` script from the app's root, so it uses the app's installed SDK. Replace the `process.env.*` names with the ones the app's init module reads, and load the app's env file with Node itself: `node --env-file=.env.local verify-sections.mjs` (Node 20.6+). No `tsx`, nothing exported in the user's shell.
```js
import contentstack from "@contentstack/delivery-sdk";
@@ -219,20 +234,19 @@ Use existing compositions for verification where possible. Any temporary authori
1. `canvasAppUrl`: used in Layer 2 (open in browser) + Layer 4 (compare against Studio project config).
2. `studioUrl`: used in Layer 4.
-3. `stackApiKey`: used in Layer 6 (curl against preview API).
-4. `PREVIEW_TOKEN`: used in Layer 6. The user exports it in their own shell (never pasted into chat, never printed) from wherever the project loads it (env file, runtime config, secrets manager, hardcoded). Do NOT assume an env file path or variable name.
-5. `previewHost`: used in Layer 6. Region-dependent (see `install-studio` § Regional host map). Custom / private data center (hosts supplied by Contentstack): the explicit host the user gives you.
-6. One ``, ``, `` triple: used in Layer 6 as a known-good probe target. Any content type the user knows has at least one entry in that environment.
-7. `previewUrl`: used in Layer 7. Any visitor URL on the canvas-app that resolves to a Connected Template with sections placed on it (e.g. `http://localhost:3006/products/some-product` for local dev). Skip Layer 7 if the project has no section compositions placed on any template yet.
+3. Credentials: none asked for. Layers 1 and 6 read the API key, Delivery Token, Preview Token and environment from the app's own env file inside a subshell, using the variable names in its init module, and print only status lines. Ask the user to run a probe themselves only when the values aren't in a file.
+4. `cdnHost` / `previewHost`: used in Layers 1 and 6. Region-dependent (see `install-studio` § Regional host map). Custom / private data center (hosts supplied by Contentstack): the explicit host from `analyze-project-fit` § 2b.
+5. ``: the app's default locale. The content type comes from Layer 1.
+6. `previewUrl`: used in Layer 7. Any visitor URL on the canvas-app that resolves to a Connected Template with sections placed on it (e.g. `http://localhost:3006/products/some-product` for local dev). Skip Layer 7 if the project has no section compositions placed on any template yet.
-If `.env` exists in the project, read `CANVAS_APP_BASE_URL` and `STUDIO_BASE_URL` as defaults for the first two before asking. Do NOT auto-read or print the Preview Token from any env file: its location is project-specific.
+If `.env` exists in the project, read `CANVAS_APP_BASE_URL` and `STUDIO_BASE_URL` as defaults for the first two before asking. Never print a token, and never load an env file outside a subshell.
## Acceptance
This skill succeeds only when Layer 0 + ALL seven downstream layers were attempted (Layer 7 may be skipped if the project has no section compositions placed on any template) and a clear pass/fail recorded for each.
-- [ ] Layer 0 confirmed (Enable Live Preview checked + Default Preview Environment set + Custom Preview URL set + Preview Token created on the Delivery Token + targeted environment has a non-empty Base URL for every locale the project serves).
-- [ ] Layer 1 result reported (delivery SDK call result or HTTP status code).
+- [ ] Layer 0 confirmed (Enable Live Preview checked + Default Preview Environment set + Custom Preview URL set where entry-opened preview is used + Preview Token created on the Delivery Token + targeted environment has a non-empty Base URL for every locale the project serves).
+- [ ] Layer 1 result reported (the content type found and the entries probe's HTTP status). No content type uid was assumed, and no credential was printed or asked for in chat.
- [ ] Layer 2 result reported (live-preview event seen / not seen within timeout).
- [ ] Layer 3 result reported (entry preview opens / fails / can't even open the entry).
- [ ] Layer 4 result reported (canvas DOM tree present in the iframe / not present / specific error string).
diff --git a/skills/studio/wire-external-data.md b/skills/studio/wire-external-data.md
index 6b5a95e..53431b5 100644
--- a/skills/studio/wire-external-data.md
+++ b/skills/studio/wire-external-data.md
@@ -120,7 +120,7 @@ This skill succeeds only when ALL of the following are true. If any fails, surfa
| `useSearchParams` in a server component | Build / runtime error | Read `searchParams` prop (App Router) or `context.query` (Pages Router) instead |
| Binding chip points at `template.*` or `contentstack.*` | Wrong picker root selected | Re-open picker, choose **Component Default Data** explicitly |
| **Binding a `file` (multiple) field directly to an `imageurl` / scalar URL prop**, renders "No image" or shows the array `.toString()` | A multiple-file field is an asset array, not a scalar URL. Binding to `images` yields the whole array. The prop expects a single string. | Bind to the **first asset's URL**: `entryBind("imageurl", "images.0.url")`. If the prop should iterate (multiple images shown), use a **Repeater** bound to the array and bind `imageurl` to `repeater.url` inside the repeater item. `.0.url` is for single-image props only. |
-| **Binding a `file` (SINGLE) field directly to an `imageurl` / scalar URL prop**, `
` renders broken / `src` is `[object Object]` / built-in `Image` renders the default placeholder | A single-file field is an **asset object** (`{ uid, url, content_type, filename, title, ... }`), NOT a URL string. Binding `featured_image` to a string-expecting prop yields the whole object. The data binder treats `imageurl` as a string. There's no special unwrap. Built-in `Image` then renders `
` (or falls back to the default-placeholder URL when the asset object stringifies to empty). | **Bind the `.url` subfield**: `entryBind("imageurl", "featured_image.url")`. For the built-in `Image` component (prop `src` typed `imageurl`), bind to `.url`, e.g. `featured_image.url`. For custom registered components consuming the asset object, you can also accept the full object and read `asset.url` yourself. A small `assetUrl()` helper handling `string \| { url }` is the safest cross-binding signature. |
+| **Binding a `file` (SINGLE) field directly to an `imageurl` / scalar URL prop**, `
` renders broken / `src` is `[object Object]` / built-in `Image` renders the default placeholder | A single-file field is an **asset object** (`{ uid, url, content_type, filename, title, ... }`), NOT a URL string. Binding `featured_image` to a string-expecting prop yields the whole object. The data binder treats `imageurl` as a string. There's no special unwrap. Built-in `Image` then renders `
` (or falls back to the default-placeholder URL when the asset object stringifies to empty). | **Bind the `.url` subfield**: `entryBind("imageurl", "featured_image.url")`. For the built-in `Image` component (prop `src` typed `imageurl`), bind to `.url`, e.g. `featured_image.url`. Same rule for custom registered components: bind `.url`, never the asset object (in 2.0.1 `imageurl` accepts only a string and reports the object as a type mismatch), and bind alt text to a separate `string` prop from `featured_image.description`. Keep an `assetUrl()` helper handling `string \| { url }` in the component as a safety net only. See [`register-component-prop-contracts`](register-component-prop-contracts.md) § Image props. |
## See also
diff --git a/skills/studio/wire-studio-state.md b/skills/studio/wire-studio-state.md
index de787ba..434fd34 100644
--- a/skills/studio/wire-studio-state.md
+++ b/skills/studio/wire-studio-state.md
@@ -26,7 +26,7 @@ Do **not** use `node -e "require('@contentstack/studio-react/package.json')"`: t
**Availability has two independent halves**: the SDK in your app and the Studio editor. The editor half (declared variables in the Data Picker, the `action` prop picker) is **not yet rolled out on every data center**, so a stack whose editor lacks it can't author BYOS even with a correct SDK build. Check the DC alongside the export: [`analyze-project-fit`](analyze-project-fit.md) § 2b.
-**`BYOS ABSENT` means stop.** Releases up to `1.7.0` are ABSENT; `1.9.0` ships it. This chapter applies only to a build that ships it. Either obtain such a build, or fall back to component-local state (a plain `useState` inside one registered component, no author binding, no cross-component sharing) and say so explicitly. Don't emit `useStudioState` against a package that can't import it.
+**`BYOS ABSENT` means stop.** Releases up to `1.7.0` are ABSENT; `1.8.0` and later ship it (checked on npm: 1.8.0's `index.d.ts` exports `registerGlobalStateVariables`, `studioState` and `useStudioState`). This chapter applies only to a build that ships it. Either obtain such a build, or fall back to component-local state (a plain `useState` inside one registered component, no author binding, no cross-component sharing) and say so explicitly. Don't emit `useStudioState` against a package that can't import it.
## Context
@@ -146,7 +146,7 @@ Registered components give Studio your markup. **BYOS gives it your behavior.**
## Acceptance
-- [ ] **Preflight passed**: the installed `@contentstack/studio-react` actually exports the state APIs (≥ 1.7.0). Nothing was written against a version that can't import them.
+- [ ] **Preflight passed**: the installed `@contentstack/studio-react` actually exports the state APIs (1.8.0 or later; 1.7.0 does not). Nothing was written against a version that can't import them.
- [ ] Every variable authors need is **declared** (init or component tier): nothing relies on an ad-hoc key being bindable.
- [ ] The tier choice is deliberate, and no key is declared in both places expecting init to win.
- [ ] `storage` is set explicitly, and for `custom` both `getState` and `setState` exist.