diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index aee13d8..ec511d9 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -4,5 +4,6 @@ "description": "Contentstack agent skills for Claude Code, Cursor, Codex, and Gemini.", "author": { "name": "Contentstack" - } + }, + "skills": "./skills/" } diff --git a/codex/AGENTS.md b/codex/AGENTS.md index e86f870..e38f31d 100644 --- a/codex/AGENTS.md +++ b/codex/AGENTS.md @@ -25,3 +25,76 @@ Use the table below to route a user request to the right skill. Each row maps a | Use when you need to automate Launch deployments for a known project and environment, monitor progress, and surface failure diagnostics. This is appropriate for CI/CD or operator workflows that need a deterministic deploy status check and log-based troubleshooting. | [Trigger and Monitor Launch Deployments](./launch-trigger-and-monitor-launch-deployments/SKILL.md) | | Use when developers ask about content personalization, A/B testing, audience segmentation, variant creation, or integrating Personalize with the CMS. | [Variants & Personalization](./cms-variants-personalization/SKILL.md) | | Use when a user needs help designing or building a Contentstack Developer Hub or Marketplace app. | [Developer Hub App Architect](./developer-hub-app-architect/SKILL.md) | + +## Studio skills + +Contentstack Studio (Composable) skill pack. Route here when the request touches Studio, canvas, compositions, templates, sections, Studio SDK, component registration, BYOC, data binding, preview wiring, provisioning, or Studio troubleshooting. + +| When the user asks… | Skill | +|----------------------|-------| +| Adapt an existing production wrapper into a Studio-compatible List Section by decomposing it into a wrapper Section (with `slot`) + Repeater + leaf adapter that wraps the real production card. This is the **compatibility-adapter path** — reach for it only when the native array-prop path (see below) won't work. | [Adapt a production collection-shaped component into a Studio List Section](./adapt-collection-component/SKILL.md) | +| Read-only diagnostic that inspects the user's project (package.json, framework, React version, Contentstack deps) and emits a tailored install path before any state-changing skill runs. | [Analyze project fit — pre-flight diagnostic for Studio install](./analyze-project-fit/SKILL.md) | +| Author Studio compositions by writing composition JSON via the CMA — the manual contract Studio's Data Picker hides. For headless / scripted / seed-pipeline projects, not interactive UI authors. | [Author a composition by API (the manual contract)](./author-composition-via-api/SKILL.md) | +| End-to-end authoring path for content teams on a Studio project a developer has already set up — pick a template, bind data, preview, save, publish — no code required. | [Author a page in Studio — no code required](./author-without-code/SKILL.md) | +| Scaffold a Connected (content-type-bound) Studio template by dropping sections, wiring linked-schema bindings, and saving — every entry of that CT renders at a derived URL. | [Build a Connected Template in Studio](./build-connected-template/SKILL.md) | +| Author a Freeform (Static) template with Additional Entry Data pins and Pinned Queries. Connected is the default; Freeform is the last resort. | [Build a Freeform template](./build-freeform-template/SKILL.md) | +| Build a Section that renders a list of cards/tiles — parent Section with Repeater + Section Slot, plus child Section bound to the iterated item. | [Build a repeating Section — parent + child pattern](./build-repeating-section/SKILL.md) | +| Author a Section composition by linking it to a structural schema (CT / Global Field / Group / Modular Block / Block / Reference) and dropping registered components. | [Build a Section](./build-section/SKILL.md) | +| End-to-end orchestrator for the BYOC arc — chains register-component (lazy) → import-design-tokens → build-section → use-section-slot → build-connected-template → configure-csr-vs-ssr → setup-template-preview-routes → verify-setup. | [BYOC end-to-end — from registered components to a rendered Template](./byoc-end-to-end/SKILL.md) | +| Decide whether a new page is a Connected (content-type-bound) or Freeform (one-off) template. **Connected is the default; Freeform is the very last resort.** Hands off to the matching build skill. | [Choose Connected vs Freeform Template](./choose-connected-vs-freeform/SKILL.md) | +| Pick and wire the right render path (CSR via `useCompositionData` vs SSR/RSC via `sdk.fetchCompositionData`) for a Studio-rendered page. | [Configure CSR vs SSR for a Studio composition route](./configure-csr-vs-ssr/SKILL.md) | +| Configure a Studio project's Environment, Language, and Canvas URL in one guided pass — replacing manual clicking through the Studio settings panels. | [Configure a Studio project](./configure-studio/SKILL.md) | +| Take a **design artefact** (a Figma frame or URL, a screenshot, a PDF mock, a wireframe, a whiteboard photo, a hand-drawn sketch, or a natural-language description of a page) and emit a full three-layer decomposition ready to feed the downstream Studio skills. Output is a machine-readable inventory of Layer-1 atomics, Layer-2 containers, Layer-2 layout components, Layer-3 Sections, and a proposed Content Type shape. | [Decompose a design into Studio's three layers](./decompose-design/SKILL.md) | +| Take a **whole-site design** (multiple templates / page kinds) and emit one consolidated schema plan plus per-template build sheets. Orchestrator around `decompose-design` — loops it per template, deduplicates atomics + Layer-2 components across pages, identifies cross-page shapes for Global Fields, and produces a single site-wide build plan the agent can execute end-to-end. | [Decompose an entire site — one schema, N build sheets, zero duplicate registrations](./decompose-site/SKILL.md) | +| Ship a Studio site to Vercel/Netlify — production env vars, composition Deploy, Live Preview gating, region/host config, cache and 401 gotchas. | [Deploy a Studio site](./deploy-studio-site/SKILL.md) | +| From a component used across routes, propose the Section's linked-schema shape and create the Global Field / Group / Modular Block so `build-section` can run. | [Design a section's linked schema from existing JSX](./design-section-from-jsx/SKILL.md) | +| Scan all route files and propose Studio Section candidates by finding components that recur across routes. Outputs a section inventory prioritised by reuse count. | [Discover sections — propose a build-order from your existing routes](./discover-sections/SKILL.md) | +| Embed a Studio-managed editable region inside a code-owned page using `useCompositionData({ compositionUid })`. Section-in-Slot, not raw component. | [Embed a Studio-managed region inside a code-owned page](./embed-composition/SKILL.md) | +| Enable Live Preview + Visual Experience at the stack level — prerequisite before any Studio / Live Preview / Visual Editor work. Without it, app installs silently fail. | [Enable Visual Experience + Live Preview at the stack level](./enable-visual-experience/SKILL.md) | +| Expose individual component props on a Section so template authors can override them per instance. | [Expose Section Props](./expose-section-props/SKILL.md) | +| Generate React components from a Figma design and register them with Studio via the Studio Figma Plugin + `csdx studio:component:sync/add/register` CLI commands. Optionally imports Figma design tokens. | [Generate Studio components from a Figma design](./figma-generate-components/SKILL.md) | +| Keep the host site's third-party scripts (cookie banners, chat widgets, GTM/analytics, ad pixels, feature-flag SDKs) out of the Studio canvas and preview sessions using the SDK's stable editor-mode contract — detect what the project loads, wrap each init in the right helper, verify. | [Gate third-party scripts out of the Studio canvas](./gate-third-party-scripts/SKILL.md) | +| Populate Contentstack Content Type entries from a customer-owned source — a JSON API, an internal JSON export, a CSV of records, an existing Contentstack stack, or a folder of Markdown files. Uses the Contentstack Management API (CMA) with idempotency + resumability so re-runs pick up where a failed run left off. Handles asset uploads for images referenced in the source. | [Import content into Contentstack CTs — idempotent, resumable, asset-aware](./import-content/SKILL.md) | +| Read a project's design tokens from Tailwind, CSS variables, or JSON and register them with Studio so the Design Panel offers them for spacing, colors, typography, radius, shadow. | [Import design tokens into Studio](./import-design-tokens/SKILL.md) | +| Optionally install + OAuth-authenticate the Contentstack MCP server so provisioning skills run CMA operations as tool calls instead of raw curl, with no manually-pasted authtoken. | [Install Contentstack MCP — optional CMA auth + execution layer](./install-contentstack-mcp/SKILL.md) | +| Install and wire `@contentstack/live-preview-utils` standalone — without the full Studio SDK — so an existing app can receive real-time edits from Contentstack into Visual Editor. | [Install Live Preview standalone](./install-live-preview/SKILL.md) | +| Optionally install the Playwright MCP server so downstream skills can drive the Contentstack web app, canvas app, and Studio directly — turning manual click-through gates into one-shot automated verification. | [Install Playwright MCP — optional acceleration layer](./install-playwright-mcp/SKILL.md) | +| Install Contentstack Studio + Live Preview + Delivery SDK in this project, configure them with stack credentials, and wire studioSdk.init at the app shell. | [Install Studio in this project](./install-studio/SKILL.md) | +| Change a live Content Type's schema — add fields, rename, retype, remove, extract to Global Field — safely. Every change classified by risk; safe changes (additive) applied directly, unsafe changes (rename, retype, remove) planned as dual-write + backfill + cutover. Dry-run mandatory before any live change. State file records every intended mutation so the run resumes on failure or reverts on demand. | [Migrate a Content Type schema — dry-run planned, dual-write executed, rollback documented](./migrate-ct-schema/SKILL.md) | +| FINAL step of a section-first migration: swap one route from hand-coded JSX to `` after components, sections, and template exist in Studio. | [Migrate one hand-coded page to a Studio template](./migrate-page-to-studio/SKILL.md) | +| Pin a Contentstack entry to a Freeform template via the Additional Entry Data section so components on that template can bind to the entry's fields. | [Pin Entry to Freeform Template](./pin-entry-to-freeform/SKILL.md) | +| Pin a CDA query (filter + sort + limit) to a Freeform template so Repeater sources iterate dynamic content like top 3 featured products. | [Pin a CDA query to a Freeform template](./pin-query-to-freeform/SKILL.md) | +| Take a project's requirements and print an architecture plan — which pieces are Sections, which pages are Connected templates, which CTs + Global Fields, build order. | [Plan the Studio architecture from project requirements](./plan-studio-architecture/SKILL.md) | +| Bootstrap a Studio project via API — create the compositions CT with the schema Studio's editor + runtime require, plus the publish + delivery-token setup. | [Provision Studio Project (API-first)](./provision-studio-project/SKILL.md) | +| Seed a stack with page CTs, global fields, entries, and assets — the prerequisites a Studio project needs OUTSIDE the compositions CT. Covers asset upload + dedupe + publish. | [Provision Studio Stack (CTs, entries, assets)](./provision-studio-stack/SKILL.md) | +| Register default/tablet/mobile breakpoints at SDK boot and verify the canvas breakpoint switcher renders. | [Register Breakpoints (default + tablet + mobile)](./register-breakpoints/SKILL.md) | +| Read a React component, infer a prop schema, and write a `registerComponent` call so Studio's palette uses the customer's component instead of built-in defaults. Lazy by default. | [Register a component with Studio](./register-component/SKILL.md) | +| Register a unified RTE renderer via `registerRTERenderer` so Studio can serialize json_rte fields, embedded entries, embedded assets, and custom element types to HTML for component props. | [Register a JSON RTE renderer](./register-json-rte/SKILL.md) | +| Set up a locally-trusted HTTPS cert (via mkcert) for the canvas-app. NOT required for most local-dev — browsers treat `localhost` as trusted and allow Studio to iframe `http://localhost:`. | [Set up locally-trusted HTTPS for the canvas-app](./setup-local-https-canvas/SKILL.md) | +| Add the canvas route that mounts `` in the user's app AND set the matching Canvas URL in Studio project settings. | [Set up the section preview route](./setup-section-preview/SKILL.md) | +| Wire a CATCH-ALL Studio route so every visitor URL resolves through Studio via ``. Direct visits and in-Studio preview share one mount. Includes explicit opt-outs. | [Wire template preview routes](./setup-template-preview-routes/SKILL.md) | +| Zero-knowledge onboarding orchestrator. Walks new users through the conceptual ladder and nudges them to build their first real Section + Template. | [Start here — Studio from zero](./start-here-zero-knowledge/SKILL.md) | +| Intent-driven end-to-end walkthrough that meets the user wherever they are and routes them through setup → register → sections → templates → ship via specialist skills. | [Studio Tour — start here](./studio-tour/SKILL.md) | +| Symptom router — classify a Studio failure (canvas, binding, URL resolution, SSR, fresh-setup) and route to the ONE specialist diagnostic skill. Don't load every diagnostic; route first. | [Troubleshoot Studio — symptom router](./troubleshoot/SKILL.md) | +| Symptom-mapped diagnostic for a non-working Studio canvas. Given a user-visible failure, narrow it to the underlying cause and propose the fix. Run a Section-test pre-flight first. | [Troubleshoot Studio canvas issues](./troubleshoot-canvas/SKILL.md) | +| Symptom-mapped diagnostic for "the URL I expected to render a composition renders the wrong one — or none." Covers URL pattern matching, `{{entry.url}}` lookup, specificity ties, and unified-URL-resolution semantics. | [Troubleshoot composition URL resolution](./troubleshoot-composition-resolution/SKILL.md) | +| Symptom-mapped diagnostic for bound fields rendering incorrectly — covers binding paths, type mismatches, repeater item context, and condition-block parent dependency. | [Troubleshoot Studio data binding](./troubleshoot-data-binding/SKILL.md) | +| Symptom-mapped diagnostic for SSR / RSC render failures with `` — covers the "use client" boundary, registry singleton, lazy-component loading, and `useData` fallback. | [Troubleshoot Studio SSR / RSC rendering failures](./troubleshoot-ssr-rendering/SKILL.md) | +| Explain API-authoring a composition vs UI authoring — the entry's `ui` (zlib node tree) + `data_sources` shape, 7 binding types, when this path fits. On-ramp before `author-composition-via-api`. | [What API-authoring a composition means](./understand-authoring-headlessly/SKILL.md) | +| Explain how Studio auto-renders data when a Section drops on a template — the algorithm matching the Section's linked schema to the template's content type. | [How auto-binding works](./understand-auto-binding/SKILL.md) | +| Explain the Canvas URL — why it must be a relative path (not a full URL), why `/canvas` is standard, and how it differs from the env Base URL (origin). | [What is the Canvas URL?](./understand-canvas-url/SKILL.md) | +| `` mounts Sections inside Studio's iframe; `` renders Templates on visitor routes AND Studio's template-preview iframe. The split is by kind, not surface. | [`` vs `` — the Sections-vs-Templates axis](./understand-canvas-vs-component/SKILL.md) | +| Explain what installing Contentstack Studio adds to a project — three SDKs (delivery / live-preview / studio-react), one shared init, two component mounts (StudioCanvas + StudioComponent). Concept on-ramp before `install-studio`. | [What installing Studio adds to your project](./understand-installation/SKILL.md) | +| Explain the **linked schema** on a Section — the declared data shape — and why declaring it once makes the Section auto-bind onto any template with a matching content type. | [What is a linked schema?](./understand-linked-schemas/SKILL.md) | +| Explain Section Slots — designated swap points inside a Section where a template author drops a different Section per instance without forking. Section-in-Slot, not raw component. | [What is a Section Slot?](./understand-section-slots/SKILL.md) | +| Explain what a Section is — a reusable page part (hero, feature grid, CTA banner) built by binding atomic React components to CMS data, optionally composing into compounds. | [What is a Section?](./understand-sections/SKILL.md) | +| Explain Templates — they compose Sections into a page-shape, rendering against a content type at a derived URL pattern (one Template → N live pages). | [What is a Template?](./understand-templates/SKILL.md) | +| Bump the Studio SDK packages (studio-react, studio-react-components, studio-client, studio-registry, studio-core, studio-internal, studio-react-editor) in the host app — pin versions, read CHANGELOGs, smoke-check, roll back if needed. | [Upgrade the Studio SDK packages](./upgrade-studio-sdk/SKILL.md) | +| Wrap component bindings inside a Repeater with sibling Condition Blocks (one per block / content type) so each iteration item resolves to a specific schema before fields bind. | [Use a Condition Block](./use-condition-block/SKILL.md) | +| Drop a Repeater on a section or template canvas and bind it to a multi-valued field so the same shape renders once per item. Two-mode mental model: bind-then-iterate. | [Use a Repeater](./use-repeater/SKILL.md) | +| Carve a Section Slot into an already-designed section so template authors drop components into a named placeholder without unlocking the surrounding design. Path B (default) — Section-with-Slot + child Section. | [Use a Section Slot](./use-section-slot/SKILL.md) | +| Run a layered smoke test confirming Studio is wired correctly — Live Preview reachable, Visual Editor opens, Studio canvas loads the canvas-app fixture, and at least one composition renders. | [Verify Studio setup end-to-end](./verify-setup/SKILL.md) | +| Drive a browser to compare a Studio-composed page against its production hand-coded counterpart, Section-by-Section at matched viewports, and classify every drift by root cause: unbound prop rendering a default, boolean flag the live call site set, prop-routing mismatch, or layout-wrapper drift. Loop until dry — no new drift class two rounds running. | [Verify visual parity — composed vs production, Section by Section](./verify-visual-parity/SKILL.md) | +| Add per-prop `defaultValue` to a registered Studio component so it renders visibly the moment an author drops it on the canvas — before any data binding. | [Wire Component Default Data](./wire-component-default-data/SKILL.md) | +| Bring external (non-Contentstack) data into a composition by fetching in the host app and passing via the `data` prop on `` — surfaced as Component Default Data. | [Wire External Data](./wire-external-data/SKILL.md) | +| Replace a route-level user-state branch with a Contentstack Personalize-driven render — read variant alias from cookies/headers, pass to `` via `variantAlias` query option. | [Wire a route-level branch to variant aliases](./wire-variant-alias/SKILL.md) | diff --git a/codex/adapt-collection-component/SKILL.md b/codex/adapt-collection-component/SKILL.md new file mode 100644 index 0000000..086513b --- /dev/null +++ b/codex/adapt-collection-component/SKILL.md @@ -0,0 +1,161 @@ +# adapt-collection-component + + +## When to use + +Adapt an existing production wrapper into a Studio-compatible List Section by decomposing it into a wrapper Section (with `slot`) + Repeater + leaf adapter that wraps the real production card. This is the **compatibility-adapter path** — reach for it only when the native array-prop path (see below) won't work. + +Use when the production wrapper's leaf takes an object shape that can't easily be reduced to scalars, when template authors need to swap different leaf Sections per template instance (Section-Slot semantics), or when the list iterates a Modular Block (polymorphic — Condition Blocks needed). Phrases — my wrapper needs template-instance swaps, per-CT rendering inside a list, migrate a carousel to Studio, wrap a legacy band in a List Section. Do NOT use if the production component already accepts an `array` prop and no per-instance swap or polymorphism is needed — use `build-repeating-section` § *When to skip the Repeater entirely — the array-prop alternative* instead (simpler, native, no adapter). + +# Adapt a production collection-shaped component into a Studio List Section + +## Which path? — decide before you build + +Studio has two ways to render a CMS collection through a production wrapper. Pick before writing any adapter: + +**Path A — native array prop** (`build-repeating-section` § *array-prop alternative*). Register the production wrapper with a `type: "array"` prop, bind that prop to a Reference field on the section's linked schema, populate `data_sources.resolvedReferences`. The SDK's data-binder reads the bound array and delivers full resolved entries — the component does its own `.map()`. No Repeater, no Condition Block. Simpler and native. + +Use Path A when: the production wrapper already accepts an `array` prop of full entry-like objects; the list iterates a **single Reference field** (one CT, no per-item polymorphism); template authors don't need to swap the child Section per template instance; you don't need Studio Preview Mode to show N rendered children on the canvas. + +**Path B — compatibility adapter** (this skill). Decompose into a wrapper Section registered with a `slot` prop + Repeater + leaf adapter. Use when Path A doesn't fit. + +Use Path B when *any* of these are true: + +- The list iterates a **Modular Block** — needs Condition Blocks per block type. +- Template authors need to **swap a different child Section per template instance** via the Section Slot. +- The production leaf's interface is object-shaped in ways that don't reduce cleanly to `array` items (nested objects, per-item variant flags Studio needs to expose to authors). +- You need Studio to show **real iteration preview** of N rendered children on the canvas (Preview Mode). + +If Path A works, use it. This skill covers Path B — the migration path when the leaf can't be simplified into an array-item shape. + +## Context (Path B) + +The Path B decomposition: + +- The **wrapper** (band chrome, layout container, header, CTA) → registered component with a `slot` prop plus scalar props for header/CTA overrides. This is a Simple Section-shaped registration — scalar props all the way — that a List Section will drop a Repeater into. +- The **iteration** (looping over items) → a Repeater dropped into the wrapper's slot at template-authoring time, bound to the multi-value field. +- Each **leaf** (one card) → a registered adapter with scalar props, wrapping the real production card 1:1. + +The leaf is the interesting reuse point: because leaf props are scalars, the Studio adapter just imports and renders the production card verbatim. Layout drifts at the wrapper; leaf pixels stay identical. + +## Task + +1. **Inventory the target component.** Read the production component + every call site. Record: which props are arrays/objects vs scalars, what layout the wrapper does (grid vs carousel vs stack), what per-item props the leaf card takes, and any data transforms the call site does before passing `items` (subtitle composition, per-CT image fallbacks, URL building, defaults). Every literal prop value at every call site is a preset the marketer will otherwise lose. + +2. **Split into three registrations.** Draw the decomposition explicitly before writing code: + + | Layer | What it does | Registration shape | + |---|---|---| + | **List Section wrapper** (wrapper) | Owns background, heading area, "View more" CTA, and the layout container (grid tracks, or the carousel scroll-viewport CSS) | Component with a `slot` prop for its main region + `imageurl`/`string`/`href`/`choice` props for header/CTA overrides | + | **Iteration** | Loops over the bound multi-value field | Not a component — it's a Repeater the template author drops into the band's slot | + | **Leaf adapter** | Renders one item | Component whose props are exactly the leaf card's scalars — mirror the production `Card`'s interface | + +3. **Register the leaf card first.** Import the production leaf card into a thin adapter and re-export it registered with scalar props. If the production leaf takes an object (`item: { title, url, image }`), the adapter destructures to scalar props (`title`, `url`, `imageUrl`) and constructs the object for the real card internally. Every scalar prop gets a `defaultValue` for palette preview. + + ```tsx + // studio-adapters/GlossaryCardAdapter.tsx + import { CaseStudyCard } from "@/components/cards/CaseStudyCard"; + + export function GlossaryCardAdapter(props: { + title: string; url: string; imageUrl: string; description?: string; + isInteractive?: boolean; // ← literal from the live call site — critical + }) { + // Reconstruct the shape the production card expects. + return ; + } + ``` + + **Import the real leaf, never reimplement it.** The whole point of this pattern is that leaves stay pixel-identical; a reimplementation defeats the purpose. + +4. **Register the List Section wrapper with a `slot` prop.** The band owns everything around the iteration; the slot is where the Repeater lands. + + ```tsx + // studio-adapters/GlossaryCardBand.tsx + export function GlossaryCardBand({ heading, subheading, ctaLabel, ctaHref, items }: { + heading: string; subheading?: string; ctaLabel?: string; ctaHref?: string; + items: React.ReactNode; // slot + }) { + return ( +
+
+

{heading}

+ {subheading &&

{subheading}

} + {ctaLabel && ctaHref && {ctaLabel}} +
+
+ {items} +
+
+ ); + } + ``` + + ⚠ Transcribe the production wrapper's CSS **line by line**. Every class (heading typography, gap, padding, item basis, peek percentages, breakpoint splits) is a drift risk if approximated. A `// KEEP-IN-SYNC WITH ` comment on both files is the minimum viable version-control gate until the visual-parity skill lands (`verify-visual-parity`). + +5. **In the section, drop a Repeater into the band's slot** and bind the Repeater's source to the multi-value entry field. Inside the Repeater, drop the leaf card adapter and bind each of its scalar props to the item's fields. If the field can hold multiple content types (a heterogeneous Reference or Modular Block), wrap the leaf adapter in a Condition Block with one branch per type — see `use-condition-block`. + +6. **Preserve display logic that lived at the call site.** The production call site often normalizes data before passing to the wrapper (subtitle composition, image fallbacks per CT, URL building). Studio's binding is a bare `field → prop` path — it has nowhere to put that logic. Route each piece to the right home: + + | Call-site logic | Where it goes in Studio | + |---|---| + | `props.copy = entry.short_description ?? entry.description` | Inside the leaf adapter — accept `copy` as prop, adapter re-routes to the right field. Or: expose both as separate props and let a preset choose. | + | `subtitle = "Related to " + entry.title` | Static text on the band, OR bind a `string` prop to the page entry (S5 dep) once page-scope binding lands. Until then: static text with a note in the migration doc. | + | `image = entry.tile_data?.thumb ?? entry.image` | Per-branch Condition Block bindings — each branch points at the right path for that CT. | + | Boolean flags set by the call site (`isInteractive={false}`, `isLogoBgWhite={false}`) | Registered as scalar props with the call-site literal as `defaultValue`. Missing this is the #1 cause of "looks similar but off." | + +7. **Carry every literal prop from every call site into `defaultValue` or a preset.** Grep the codebase for every JSX use of the production wrapper. Each literal (`variant="compact"`, `columns={3}`, `showRating={false}`) becomes either a `defaultValue` on the adapter's corresponding prop, or a named preset. Skipping this is what makes the composed page "look 80% right" — see the `isInteractive` bug in the meta-observation. + +8. **Match the CSS mechanism the production band uses.** Carousels are the classic trap: + + | Production mechanism | Studio adaptation | + |---|---| + | Embla / Swiper (JS library that measures children) | ⚠ Repeater wraps each iteration in `display:contents`, so JS libraries see zero-width items and break. Use a **CSS-only** carousel (scroll-snap + `overflow-x: auto` + item `flex-basis` with peek), OR wrap each iteration in a real DOM element (product gap — file as Part 1 #10). | + | CSS Grid | Grid tracks on the band's slot container — leaf just fills its cell. | + | Flexbox with `flex-wrap` | Same layout on the slot container. Leaf renders `width: 100%`. | + + For the carousel case: gap-subtracted `flex-basis` (`calc(90% - var(--gap))`) prevents the last item peek from being cropped by the container's right padding. + +9. **Verify per band with the visual-parity skill.** Run `verify-visual-parity` (or the `docs/for-developers/migration/` playbook's manual pass) at authored data — real entries pinned — for each band as it lands, not once at the end. Drift compounds if you defer verification; catching it band-by-band is 10× faster than debugging a whole page. + +## Inputs needed from the user + +1. Path to the production wrapper component (the one taking an array/object prop). +2. Path to the production leaf card. +3. The entry field the collection binds to (Reference / Modular Block / multi-Group). +4. Any call sites of the production wrapper that carry literal props (grep-able list). + +## Acceptance + +- [ ] List Section wrapper registered with a `slot` prop for the iteration region + scalar props for header/CTA. +- [ ] Leaf adapter registered with scalar props only; imports the production leaf verbatim (no reimplementation). +- [ ] Every literal prop from every production call site is preserved as a `defaultValue` or preset. +- [ ] Wrapper CSS transcribed line-by-line from production; both files carry a `KEEP-IN-SYNC` comment naming the counterpart. +- [ ] Repeater bound to the multi-value field; heterogeneous fields wrapped in a Condition Block (see `use-condition-block`). +- [ ] Carousel bands use CSS-only mechanisms (scroll-snap + `flex-basis`), not JS libraries that measure children. +- [ ] `verify-visual-parity` run against a pinned entry with real data; drift classified and closed band by band. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +|---|---|---| +| Reimplementing the leaf card in the adapter | Diverges from production immediately; the "same code, same pixels" property is lost | Import the production leaf and pass reconstructed props. Never re-write. | +| Skipping the call-site literal sweep | Production sets `isInteractive={false}`, `variant="compact"`, etc. — the adapter registers these as `defaultValue: true` (framework default). Rendered page has correct data but wrong behavior. | Grep every JSX use of the production wrapper. Carry every literal into `defaultValue` or a preset. | +| Approximating wrapper CSS classes | Heading typography drifts (`cs-h2` vs `h1-display-compact`); item basis peeks wrong; gaps mismatch | Transcribe class-by-class; add a `KEEP-IN-SYNC` comment naming the production file. | +| Using Embla/Swiper inside a Repeater | Repeater's `display:contents` wrappers zero out item widths → library computes garbage | CSS-only carousel (scroll-snap + `basis` + `overflow-x`); or file the real-DOM Repeater option (product gap). | +| Data normalization dropped on the floor | Production composed subtitles / per-CT image fallbacks / URL builders lived at the call site; Studio's bindings are bare paths, so the logic silently disappears | Route each transform per the decision table in Step 6 (adapter / static / Condition Block branch / product-gap flag). | + +## See also + +- `build-repeating-section` — the greenfield flavor of List Section authoring (no production wrapper to preserve). +- `understand-sections` — Simple Section vs List Section distinction; how they compose. +- `register-component` § *Array & object props do not bind* — why arrays/objects don't bind; when to reach for this skill instead. +- `use-repeater` — how to bind the Repeater to the multi-value field. +- `use-condition-block` — required inside the Repeater when the field is heterogeneous. +- `use-section-slot` — how to carve the slot the Repeater drops into. +- `verify-visual-parity` — verification loop; run per List Section, not once at the end. +- `docs/for-developers/migration/` playbook — the codified end-to-end brownfield migration this skill lives inside. diff --git a/codex/analyze-project-fit/SKILL.md b/codex/analyze-project-fit/SKILL.md new file mode 100644 index 0000000..f3cc7a5 --- /dev/null +++ b/codex/analyze-project-fit/SKILL.md @@ -0,0 +1,152 @@ +# analyze-project-fit + + +## When to use + +Read-only diagnostic that inspects the user's project (package.json, framework, React version, Contentstack deps) and emits a tailored install path before any state-changing skill runs. + +Use as the FIRST step when the user wants to add Studio to an existing project, or is unsure if their setup is compatible. Phrases — "add Studio to my app", "is my project compatible", "before I install". Always run before `install-studio`, `install-live-preview`, or `setup-section-preview`. Skip if already run this session. + +# Analyze project fit — pre-flight diagnostic for Studio install + +## Why this skill exists + +`install-studio` assumes a known project shape. In reality projects vary across: + +- **React version** — the SDK is built for React 18; React 19 is unsupported. The SDK's peerDep is now `react ^18.0.0`, so npm/pnpm refuse to install Studio on a React 19 project — the incompatibility surfaces immediately at install rather than at runtime. +- **Framework** — Next.js needs `NEXT_PUBLIC_*` env prefixes and SSR-safe init; Vite needs `dedupe` and a prod-build canvas; CRA needs `REACT_APP_*`. +- **Bundler quirks** — Next 16 + Turbopack has tree-shake edge cases; custom webpack configs may not externalize React. +- **Existing Contentstack SDKs** — adding Studio to an app that already has `@contentstack/delivery-sdk` is a minimal-add, not a greenfield install. +- **Local serving** — Studio iframes the canvas; `http://localhost` is mixed-content blocked, a self-signed HTTPS cert is untrusted; mkcert must be installed. + +Running blind through `install-studio` against these variants is what produces the long debug sessions documented in `STUDIO_INTEGRATION_ISSUES.md` (React 19 hook-call, dev-optimizer dual React, missing trusted HTTPS, etc.). This skill front-loads the inspection so the next skill receives a clean, known shape. + +This skill is **read-only**. It never writes files, never installs packages, never modifies the project. It produces a plan; the user confirms; then the appropriate downstream skill runs. + +## Task + +### 1. Inspect the project (no writes) + +Run these checks in order: + +```bash +# 1a. package.json — root + workspace packages if monorepo +cat {{projectRoot}}/package.json + +# 1b. Lockfile → package manager +ls {{projectRoot}}/pnpm-lock.yaml {{projectRoot}}/yarn.lock {{projectRoot}}/package-lock.json {{projectRoot}}/bun.lockb 2>/dev/null + +# 1c. Workspace / monorepo signals +ls {{projectRoot}}/turbo.json {{projectRoot}}/pnpm-workspace.yaml {{projectRoot}}/nx.json {{projectRoot}}/lerna.json 2>/dev/null + +# 1d. TypeScript presence +ls {{projectRoot}}/tsconfig.json 2>/dev/null + +# 1e. Single React check (the dual-React problem) +cd {{projectRoot}} && npm ls react 2>&1 | head -20 + +# 1f. Bundler configs +ls {{projectRoot}}/vite.config.* {{projectRoot}}/next.config.* {{projectRoot}}/webpack.config.* {{projectRoot}}/tsup.config.* 2>/dev/null + +# 1g. Local HTTPS readiness +which mkcert ; ls {{projectRoot}}/certs/*.pem 2>/dev/null + +# 1h. Deploy target +ls {{projectRoot}}/.vercel/ {{projectRoot}}/netlify.toml {{projectRoot}}/vercel.json {{projectRoot}}/wrangler.toml 2>/dev/null +``` + +### 2. Classify the project + +From the output above, extract: + +| Signal | Detection rule | +|---|---| +| **Framework** | `package.json` deps: `next` → Next.js · `vite` → Vite · `react-scripts` → CRA · `@remix-run/*` → Remix · `astro` → Astro · else → Custom | +| **React version** | `package.json` deps `react` field → version range; resolve to actual installed major via `npm ls react`. Classify as **18**, **19**, **17 or older** | +| **Single React** | `npm ls react` must show exactly one tree entry on 18.x — if it shows two entries or a deduped warning, that's a duplicate risk | +| **Package manager** | `pnpm-lock.yaml` → pnpm · `yarn.lock` → yarn · `bun.lockb` → bun · `package-lock.json` → npm | +| **TypeScript** | `tsconfig.json` exists | +| **Monorepo** | Any of `turbo.json` / `pnpm-workspace.yaml` / `nx.json` / `lerna.json` | +| **Existing Studio integration** | Any `@contentstack/studio-*` already in deps | +| **Existing CS app** | Any `@contentstack/*` already in deps (not Studio) — implies minimal-add | +| **Next + Turbopack** | Next deps + `next dev --turbo` or `next dev --turbopack` in scripts | +| **mkcert ready** | `which mkcert` returns a path | + +### 3. Choose a path + +Pick exactly one based on the classification: + +| Path | Detected shape | What to do next | +|---|---|---| +| **A. Greenfield-friendly** | Vite/CRA + React 18 + single React + no existing CS deps | Run `install-studio` directly. No pre-work needed. | +| **B. React-19 → pin React (Next 15) OR downgrade Next (React-19-only)** | React 19 detected | **Block** `install-studio` first. **Evidence:** `@contentstack/studio-react` peer-deps `react ^18.0.0` on every published version (public npm through 1.5.0; GitHub Packages through 1.6.x); no validated React 19 build ships today. The reference customer app (`studio-demo-rb`) runs React 18.3.1 + studio-react 1.6.0. **Two fixes — pick the narrower one first:** (B1) **Pin React only** — ` add react@18.3.1 react-dom@18.3.1 @types/react@18 @types/react-dom@18 --save-exact`. Works on Next 15 without touching Next (Next 15 peer-allows `react ^18.2.0`, so no Next downgrade needed). (B2) **Downgrade React AND Next** — only when the app has code that requires React 19 features (Actions, `use()`, RSC async APIs). Follow the Next 14 downgrade path. Re-run `analyze-project-fit` to confirm single React 18.x. When a React-19-compatible SDK ships, run `upgrade-studio-sdk` to migrate off the pin. | +| **C. React 17 or older** | React < 18 detected | **Block** `install-studio`. The user must upgrade to React 18 first. Refer them to the React 18 upgrade guide. The SDK won't work on 17 — this is a hard requirement, not a soft one. | +| **D. Existing CS app (minimal-add)** | `@contentstack/delivery-sdk` or `@contentstack/live-preview-utils` already present | Do NOT re-init Delivery SDK. Take the *minimal-add* branch in `install-studio` (Studio-only install, reuses existing Delivery SDK init). | +| **E. Next + Turbopack** | Detected | Proceed to `install-studio` with Next-specific paths. Flag: SDK ships a canvas-route built-ins auto-bootstrap, but if the user is pinned to an older SDK there's a known Turbopack tree-shake regression — `troubleshoot-canvas` has the safety-net snippet. | +| **F. Custom bundler** | Webpack/tsup/rollup with no `external: ['react']` detected | Walk the user through `dedupe` config OR (recommended) switch to serving a prod build for the Studio canvas. The dev bundler is the most common source of dual-React issues. | +| **G. Multiple React instances** | `npm ls react` shows >1 entry | **Block** `install-studio`. Resolve duplicates first: identify which dep pulls the second React (`npm ls react`), add `overrides`/`resolutions` in `package.json` to pin a single version, reinstall, re-run `analyze-project-fit`. | + +### 4. Report — what to tell the user + +Print a single readable summary block: + +``` +✓ Project shape detected: + Framework: + React: <18.3.1 | 19.x | …> — + Package manager: + TypeScript: + Monorepo: + Existing CS SDKs: + Local HTTPS: + Deploy target: + +Chosen path: + +Next steps (in order): + 1. + 2. Invoke skill: + 3. Then: + +Blockers to resolve before install: + • +``` + +Wait for explicit user confirmation before invoking the next skill. + +### 5. Hand-off + +Once the user confirms, invoke the next skill in the chosen path. Pass forward what you discovered (framework, package manager, React version, TS) so the downstream skill doesn't re-prompt for things you already know. + +## Inputs needed from the user + +1. `projectRoot` — path to the project. Usually `.` (current dir). + +## Acceptance + +This skill succeeds only when ALL of the following are true. + +- [ ] Inspection commands ran and outputs were classified (no guessing — every signal in the classification table either matched a detection rule or was reported as "not detected"). +- [ ] Exactly one path (A–G) was chosen with justification. +- [ ] The summary block was printed including the chosen path + next steps. +- [ ] If the path is a blocker (B/C/G), `install-studio` was NOT invoked — the user is given the prep step and asked to re-run `analyze-project-fit` after. +- [ ] If the path is an enabler (A/D/E/F), the downstream skill was invoked with the discovered context. +- [ ] No files were modified by this skill itself. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| Trusting `package.json` `react` field over `npm ls react` | The dep range may say `^18.0.0` but the actual installed version could be 18.x or higher depending on resolution + lockfile | Always run `npm ls react` to confirm the installed major | +| Assuming React 19 will work | SDK's peerDep is `^18.0.0` — install fails fast on React 19 (good). Path B handles the prep step so the user gets a clear pin command rather than a cryptic ERESOLVE log | Path B detects React 19 up front and pins to 18.3.1 before invoking install-studio | +| Skipping the monorepo check | Studio installed in the wrong workspace package doesn't get picked up by the canvas-app | Detect `pnpm-workspace.yaml` / `turbo.json`, ask which workspace package is the canvas-app | +| Routing every project through Path A by default | The smooth path only applies to a specific shape | Use the classification table — if any signal points to B–G, go there | +| Re-running `analyze-project-fit` after the user makes a change without re-running the inspection commands | Stale classification = wrong path | After any user-side prep (downgrade, dedupe), re-run inspection commands fresh | + +## See also + +- `install-studio` — the downstream skill for paths A/D/E/F +- `setup-section-preview` — for the canvas-iframe wiring (paired with `setup-local-https-canvas`) +- `setup-local-https-canvas` — mkcert recipe for trusted local HTTPS +- `troubleshoot-canvas` — reactive diagnosis if install proceeded and something failed downstream +- `verify-setup` — layered smoke test after install diff --git a/codex/author-composition-via-api/SKILL.md b/codex/author-composition-via-api/SKILL.md new file mode 100644 index 0000000..66dacc9 --- /dev/null +++ b/codex/author-composition-via-api/SKILL.md @@ -0,0 +1,1097 @@ +# author-composition-via-api + + +## When to use + +Author Studio compositions by writing composition JSON via the CMA — the manual contract Studio's Data Picker hides. For headless / scripted / seed-pipeline projects, not interactive UI authors. + +Use when authoring compositions programmatically via CMA — seed pipelines, scripted provisioning, migration scripts, debugging via diff. Phrases — "compositions from JSON", "API author composition", "seed compositions", "headless author", "bulk import". Do NOT use for UI authoring — the Data Picker handles every shape this skill describes. For UI use `build-section` / `build-connected-template`. + +# Author a composition by API (the manual contract) + +## When to use this — and when NOT to + +Studio's Data Picker writes every binding shape this skill describes **automatically**. UI authors never need to know about `data_sources.resolvedReferences`, the `repeaterUID` discriminator, or any of the binding types — Studio's runtime resolves them under the hood. + +**Use this skill ONLY** when bypassing the Data Picker: +- A seed pipeline that creates compositions programmatically (a docs / demo project's bootstrap, an automated stack provisioner) +- Migrating compositions between stacks +- Bulk-authoring compositions from another source (an existing CMS, a generator) +- Debugging — capture a working UI-authored composition, decode it, diff against your broken one + +For interactive authoring, use Studio's UI. The Data Picker is the **source of truth** for these shapes. If your manually-written shape disagrees with what the Data Picker emits, the picker is right; your manual shape is wrong. + +## Prerequisite — the project, the compositions CT, the credentials + +This skill assumes: +- A Studio project exists, bound to a stack +- That stack has a compositions CT (the CT the project's `contentTypeUid` points at) with the required field schema — see `provision-studio-project` +- You have a CMA auth token + the stack API key with permission to create entries on that CT +- For headless verification later: a Delivery Token + paired Preview Token (`enable-visual-experience` Step 4) + +If any of these is missing, this skill is the wrong starting point. + +> **MCP-authed path (optional).** If `CONTENTSTACK_MCP_READY` (from [`install-contentstack-mcp`](../install-contentstack-mcp/SKILL.md)), 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. No flag → raw curl. + +## The composition entry — what you actually write to the CMA + +A composition is a single entry on the compositions CT. The fields (see § *Complete example* below for a real published Freeform composition): + +| Field | Type | Holds | +|---|---|---| +| `title` | string | Display name in the Compositions list | +| `url` | string | URL pattern (e.g. `/products/{{entry.slug}}` for Connected; a fixed path like `/campaigns/spring-2026` for Freeform). Populated for both kinds | +| `composable_uid` | string | Composable identity for the composition. Must be **unique across the compositions CT**. Studio's canvas links to compositions by entry uid but the runtime resolves by `composable_uid` — a human-readable slug (`"spring_2026_landing"`) or the entry uid both work. Mismatch → "Composition Not Found" | +| `connected_content_type` | string | The CT this template binds to (Connected only). Empty string `""` for Freeform / sections | +| `place_composition_as` | string | `"page"` for a template composition (Connected or Freeform). `"section"` for a section composition. `"template"` also legal but not what Studio writes for the top-level template composition — the Compositions tab lists `"page"` entries under Templates | +| `schema_version` | string | Composition schema version stamped by Studio (`"1.0.0"`, `"1"`, `"2"`, …) — a **string**, not a number | +| `ui` | string | The compressed composition tree — `zlib:` (see `troubleshoot-canvas` § *Diagnostic tooling*) | +| `data_sources` | JSON string | The stringified `resolvedReferences` map + pinned data-source list — wire shape is `"[]"` when empty, not `[]` | +| `static_value` | group | Per-key literal store — every `binding.type: "static_value"` on a node references a **key** here; the actual literal lives in `static_value.[]` under that key. See § *How `static_value` binding resolves* below | +| `url_metadata` | group | `url_source` (`content_type_url_pattern` / `user_specified_pattern`), `url_queries` — omitted when unused | +| `linked_schemas` | **group-multiple** | Sections only — declares parent CT + `selectedField` the section scopes to. `[]` for templates / freeform | +| `linked_sections` | reference (multiple) | Template's references to section-composition entries placed in its `ui` tree. `[]` for freeform / sections | + +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. + +### How `static_value` binding resolves — two-level lookup + +**Not obvious from the binding shape** — the literal ISN'T on the node. The `binding.value` on a `static_value` binding is a lookup key into the composition entry's `static_value.[]` bucket: + +1. The node's prop binding stores a **key string** in `binding.value` (e.g. `"mHjnXsgKbBlj_PZ-headline"`), NOT the literal. +2. The literal lives in the composition entry's top-level `static_value.[]` array under that key: `{ "key": "mHjnXsgKbBlj_PZ-headline", "value": "Welcome to Studio" }`. +3. The SDK resolves by keying into `entry.static_value` with that key at render time. + +Consequences for API authoring: + +- **You must write to TWO places** for every static-value prop: `props..binding.value = ""` on the node AND `entry.static_value.` (matching the prop's declared `type` — `text` / `textarea` / `href` / `imageurl` / `number` / `choice` / `boolean` / `json_rte` / `array` / `object` / `any` / `datestring` / `html_rte`) with an entry `{ key, value }` pair. +- **Key uniqueness matters** — the resolver looks up the key against the whole `static_value.` bucket. Reuse the same key across multiple nodes to share a literal; use unique keys to keep them independent. +- **Widget → subtype pairing.** The node's `props..type` (`"string"` / `"number"` / `"href"` / `"imageurl"` / `"choice"` / …) determines which sub-bucket the SDK looks in. If you write the key to the wrong subtype array, the resolver misses and the prop renders as the schema default. +- **Convention Studio uses:** keys are `-` (e.g. `mHjnXsgKbBlj_PZ-headline`). Not required but stable and readable. + +## Binding types — the full set + +The SDK defines **7 binding `type` values**. Each type is available in a specific composition context; picking one that doesn't fit the context is the most common trap. + +| `type` | Available in | Configured in Studio via | Shape | +|---|---|---|---| +| `static_value` | Connected + Freeform + Sections (always) | Right panel — Settings → type the literal into the prop input | `{ type: "static_value", value: "" }` — where `` looks up `entry.static_value.[]`. See § *How `static_value` binding resolves* | +| `template` | Connected templates + Sections placed on Connected templates | Right panel — Data Picker → pick a field on the connected entry | `{ type: "template", value: { path: {…} } }` | +| `repeater` | Anywhere inside a Repeater | Right panel — Data Picker under the Repeater's scope; auto-picked | `{ type: "repeater", value: { repeaterUID: "", path: {…} } }` | +| `contentstack` | **Freeform only** — Data tab → **Additional Entry Data** (Pinned Entries) | Data tab → pin an entry, then Data Picker binds to its fields | `{ type: "contentstack", value: { uid, _content_type_uid, path: {…} } }` | +| `contentstack_queries` | **Freeform only** — Data tab → **Queries** (Pinned Queries) | Data tab → save a query, then Repeater `items` picks it | `{ type: "contentstack_queries", value: { queryUID: "", path: {…} } }` | +| `component_props` | Anywhere — component's registered `defaultValue` OR Data tab → **External Data** | Registration path (`registerComponent`) or Data tab External Data | `{ type: "component_props", value: "" }` | +| `symbol_props` | Symbols only (advanced) | Symbol authoring | `{ type: "symbol_props", value: "" }` | + +`path` is a nested-object leaf structure: `{ products: { 0: { url: {} } } }` reads `products.0.url`. The path is flattened at resolution; `{ products: {} }` and `{ products: { "": {} } }` are equivalent. + +### Authoring UX — the same for every binding kind + +**No inline click-to-edit exists on the canvas for any binding kind.** Every value edit happens in the right panel: + +- **Static bindings** — edited in the **Settings** tab. Type into the prop input; the literal writes to `binding.value`. +- **Template bindings** — edited via the **Data Picker** in Settings (pick a different field on the connected entry). The rendered value itself is read-only — to change the underlying content, edit the connected entry in the CMS. +- **Pinned bindings** (`contentstack`, `contentstack_queries`) — the pin itself is set up in the **Data** tab (Freeform only). Once pinned, bindings reference it through the Data Picker in Settings. Rendered values are read-only from Studio's perspective; edit the source entry in the CMS to change them. + +### Context mismatches — the real trap + +Picking a binding type that doesn't fit the composition's context is silent — the shape is valid, but resolution returns `undefined` at render: + +| Mismatch | Symptom | Fix | +|---|---|---| +| `template` binding in a **Freeform** template | Binding never resolves — Freeform has no page-level entry to source from | Use `contentstack` (pinned entry) or `contentstack_queries` (pinned query) via the Data tab, or `static_value` for a literal | +| `contentstack` / `contentstack_queries` binding in a **Connected** template | The binding works, but it bypasses the connected entry — you've hard-pinned data that should come from the page. Usually a mistake | Use `template` with a path into the connected entry's schema | +| `repeater` binding used on a node NOT inside a Repeater | `repeaterUID` refers to a scope that doesn't exist above the node — resolves to `undefined` | Place the node inside the referenced Repeater's `slots`, or switch to a `template`/`contentstack` binding matching the current scope | + +## Node anatomy — where bindings and children actually live in the `ui` tree + +Node shape (canonical Repeater example, binding wrapper included): + +```jsonc +{ + "type": "", // "repeater" / "condition-block" / a registered component type + "uid": "", + "metadata": { // node-level metadata + "mode": "preview" // - Repeater: "preview" vs implicit Design Mode + // "condition": { ... } // - Condition Block: discriminator (see below) + }, + "props": { // bindings live HERE — one per prop + "": { + "binding": { "type": "", "value": { /*…*/ } }, + "value": "" + } + }, + "slots": { // children go HERE, grouped by slot uid (NOT a flat `children` array) + "": [ + { /* child node */ } + ] + } +} +``` + +Three things to internalize: + +1. **Bindings are at `props..binding`.** Not directly on the node, not in a flat `bindings` map. +2. **Children are in `slots.: [...]`.** Not a `children` array. Each slot has a uid; child nodes live inside the array under that uid. +3. **Discriminators live in `metadata`.** Condition Block's `condition` and Repeater's `mode` are both `metadata.`, not top-level node fields. + +### Built-in node `type` values + +These are the built-in `type` strings the SDK ships with; anything else must be a component you registered via `registerComponent`. + +| Group | `type` values (palette label → `type` string) | +|---|---| +| System | `page` | +| Basic — text | `header` · `plain-text` · `collapsible-text` · `rich-text` · `text` · `number` · `json-rte` | +| Basic — button/link | `button` · `link` · `link-container` | +| Media | `image` · `video` · `embed` | +| Container / layout | `section` · `box` · **`hstack`** (palette: Columns) · **`vstack`** (palette: Rows) · `fragment` | +| Composition primitives | `symbol` · `condition-block` · `section-slot` | +| Iteration | `repeater` | +| HTML | `html-element` · `style-sheet` | + +Palette labels are author-facing; `type` strings are what appear in the `ui` tree. **Non-obvious pairings:** palette *Columns* → `hstack`, palette *Rows* → `vstack`. Inspect a UI-authored composition (decode `ui`, see [`troubleshoot-canvas`](../troubleshoot-canvas/SKILL.md) § *Diagnostic tooling*) to confirm the exact `type` string before hand-authoring a node of that kind. + +None of these support inline click-to-edit on the canvas — all value edits go through the right panel (both built-ins and BYOC — see [`build-section`](../build-section/SKILL.md) § *Basic field components vs custom registered components*). + +## 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). + +Scenario: a `category` template (Connected to the `category` CT). Each category entry has a `products` multi-reference field. We want to render one card per referenced product. + +### 1. Composition-level: `data_sources.resolvedReferences` + +The reference-resolution map. Tells the SDK "when bindings resolve through this reference path, materialize each stub into a real entry": + +```jsonc +"data_sources": [ + { + "uid": "template", + "data": null, + "resolvedReferences": { + "template": ["products"] + } + } +] +``` + +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. + +**Key semantics:** the KEY of `resolvedReferences` is an opaque label — Studio uses `"template"` for template-scope references. The SDK flattens `Object.values(resolvedReferences)` and treats the union as the reference paths to include, so the key content doesn't affect resolution. Values are the field paths on the parent entry. + +Without this, reference fields are just `[{uid, _content_type_uid}, …]` stubs at runtime. The iteration sees no items. + +### 2. Repeater node's `items` binding — at `props.items.binding`, type `template` + +The Repeater consumes its iteration source through its `items` prop: + +```jsonc +{ + "type": "repeater", + "uid": "R1", + "props": { + "items": { + "binding": { + "type": "template", + "value": { "path": { "products": {} } } + } + } + }, + "metadata": { "mode": "preview" }, + "slots": { /* see step 4 */ } +} +``` + +For a section whose `selectedField` is the reference itself (`build-section`'s scope-aware sections / P22-P24), the path collapses to scope-root `{ "path": {} }` — the section's `dataSources.template` is already the resolved array. + +### 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 → 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`) + +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. + +The Condition Block lives under a slot of the Repeater. Its discriminator is in `metadata.condition`: + +```jsonc +{ + "type": "repeater", + "uid": "R1", + // …props / metadata as above… + "slots": { + "": [ + { + "type": "condition-block", + "uid": "CB1", + "metadata": { + "condition": { "type": "reference", "value": "product" } + }, + "slots": { + "": [ + /* card subtree here — step 5 */ + ] + } + } + ] + } +} +``` + +For modular-block iteration: `metadata.condition.type: "modular_block"`, `value: ""`. + +### 5. Card prop bindings — type `repeater`, with `repeaterUID` + +⛔ **The "all cards identical" trap:** Do NOT use `template` bindings indexed at `.0.` (e.g. `products.0.title`). A fixed `.0.` index does NOT get substituted by the iteration — every iteration renders item 0. This is what bit P19. + +Use repeater-scope bindings — `type: "repeater"`, with `repeaterUID` naming the parent Repeater node, and a path relative to the iteration item (NO index). The card node lives inside the Condition Block's slot: + +```jsonc +{ + "type": "site-product-card", // the registered component's type + "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": {} } } + } + } + } +} +``` + +The `repeaterUID` ("R1" here) tells the SDK at render time which Repeater's current item this binding reads from. The path is on the iteration item itself. + +### Assembled + +``` +Composition entry +├── composable_uid: "<15-char>" (= CMA entry uid) +├── data_sources: [{ uid:"template", +│ resolvedReferences:{ "template":["products"] } }] +└── ui (zlib-inflated tree) + └── Section "ProductGrid" + └── Box (grid layout — register-component § 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:{ type:"reference", value:"product" } }, + 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:{} } } } } + } } + ] } } + ] } } +``` + +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 → empty render or all-identical render. + +## Recipe — Repeat a card over a GROUP-MULTIPLE field + +Sibling to the multi-reference recipe above, but **simpler**: group-multiple values are inline on the parent entry, so no reference resolution is needed. + +Scenario: a `category` entry has a `features` field of type `group` + `multiple: true` (a list of inline objects, each with sub-fields like `headline`, `body`, `icon`). Render one card per feature. + +### What's different vs the multi-reference case + +| 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 | +| 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 | + +So group-multiple drops to **THREE shapes** (Repeater items binding + Repeater metadata.mode + repeater-scope card bindings). No composition-level resolved-references map. No Condition Block. + +### Group-multiple Repeater + card, assembled + +``` +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 § 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:{} } } } } } + } } + ] } } +``` + +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. + +### Decision rule — which recipe to use + +Look at the field's `data_type` on the source CT: + +| Field shape | Recipe to use | +|---|---| +| `data_type: "reference"`, `field_metadata.ref_multiple: true` (multi-reference) | Multi-reference recipe — needs `resolvedReferences` + Condition Block | +| `data_type: "reference"`, `field_metadata.ref_multiple: false` (single reference) | Multi-reference recipe minus the Repeater (a single Section drop, not iteration). The reference still needs `resolvedReferences`. | +| `data_type: "group"`, `multiple: true` (group-multiple) | Group-multiple recipe — no `resolvedReferences`, no Condition Block | +| `data_type: "blocks"` (Modular Block) | Multi-reference recipe shape, but Condition Block discriminator is `metadata.condition.type: "modular_block"`, value is the block UID | +| `data_type: "file"`, `multiple: true` (multi-file) | Multi-reference recipe minus `resolvedReferences` (files don't need resolution); minus Condition Block (one shape). Like group-multiple. Path uses `.0.url` etc. inside each iteration. | +| `data_type: ""` (array of strings, numbers, etc.) | Group-multiple recipe shape — each iteration is one scalar; the card might be a single component reading the scalar via repeater scope | + +The OPEN doc's P19 captures exactly the group-multiple vs reference split: same iteration shape, different prerequisites (`resolvedReferences` + Condition Block) depending on the source. + +## Recipe — Nested Repeaters (a list inside each iterated item) + +A Repeater inside another Repeater's iteration — e.g. a footer with multiple **columns**, each column having its own **links** list. The outer Repeater iterates columns; the inner Repeater iterates each column's links. The trick is the inner Repeater's `items` binding: it must reach into the **outer iteration's current item** to find that column's `links` array — which means `type: "repeater"` (with the outer's `repeaterUID`), not `type: "template"`. + +### The two binding shifts + +| Node | `items` binding `type` | `value` | What it resolves to at runtime | +|---|---|---|---| +| **Outer Repeater** (`R-outer`) | `template` | `{ path: { footer_columns: {} } }` | `dataSources.template.footer_columns` (the array on the parent entry) | +| **Inner Repeater** (`R-inner`) | **`repeater`** | `{ repeaterUID: "R-outer", path: { links: {} } }` | `dataSources.repeater.R-outer.context.links` (each outer iteration's `links` field) | +| **Inner card props** | `repeater` | `{ repeaterUID: "R-inner", path: { label: {} } }` | `dataSources.repeater.R-inner.context.label` (each link's `label`) | + +Repeater-scoped bindings resolve as `dataSources.repeater..context.`. So the inner Repeater's items binding correctly reads the outer iteration's current `links`, and the inner card's bindings read each link inside that. + +### Assembled + +```jsonc +{ "type": "repeater", "uid": "R-outer", + "metadata": { "mode": "preview" }, + "props": { "items": { "binding": { + "type": "template", + "value": { "path": { "footer_columns": {} } } + } } }, + "slots": { "": [ + /* outer iteration body — one column */ + { "type": "site-column-header", "uid": "CH1", + "props": { "title": { "binding": { + "type": "repeater", + "value": { "repeaterUID": "R-outer", "path": { "heading": {} } } + } } } }, + { "type": "repeater", "uid": "R-inner", + "metadata": { "mode": "preview" }, + "props": { "items": { "binding": { + "type": "repeater", // ← NOT template + "value": { "repeaterUID": "R-outer", // ← outer's UID + "path": { "links": {} } } // ← path on outer's iteration item + } } }, + "slots": { "": [ + { "type": "site-footer-link", "uid": "L1", + "props": { + "label": { "binding": { "type": "repeater", + "value": { "repeaterUID": "R-inner", "path": { "label": {} } } } }, + "href": { "binding": { "type": "repeater", + "value": { "repeaterUID": "R-inner", "path": { "href": {} } } } } + } } + ] } } + ] } } +``` + +### Source-type combos + +| Outer source | Inner source | Extra prerequisites | +|---|---|---| +| group-multiple (e.g. `footer_columns`) → group-multiple (`column.links`) | (nothing extra) — inline values both ways | +| group-multiple → 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 → 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`. | +| multi-reference → multi-reference | `resolvedReferences` entries for BOTH the outer reference AND each inner reference path. Condition Block in both Repeaters. | + +The decision tree from the single-level recipes (group-multiple vs reference) applies per Repeater independently — outer's source determines what `R-outer` needs; inner's source determines what `R-inner` needs. + +### The key pitfall + +⛔ Setting the inner Repeater's `items` binding to `type: "template"` with path `{ footer_columns: { 0: { links: {} } } }` is the **nested-`.0.` trap** — same family as the "all cards identical" trap from #14/P19, but one level up. A fixed index doesn't substitute. Inner repeater would always read column-0's links, so every outer iteration's links list is identical. Use `type: "repeater"` with the outer UID instead. + +## Authoring a Section composition — scoping rules (P22-P25) + +So far the recipes have been about page-level compositions (Templates). Authoring **Section** compositions via API has its own contract — sections have **no data of their own**, so how they get the right scope at runtime is non-obvious. + +### How a Section gets data when placed on a Template — `selectedField` (P22) + +A Section composition is data-less when authored standalone (its own canvas has nothing to bind against). It only gets data when **placed inside a Template** — the Template hands it a scoped slice of the page entry. + +The SDK's scoping pipeline: + +``` +Section's dataSources.template = +``` + +- **`selectedField` empty/absent** → section's `template` = the **whole page entry** (with any reference following). Use this when a section reads multiple fields off the page. +- **`selectedField` = "products"** → section's `template` = the **`products` field's value** (an array, with references resolved). Use this when a section iterates one field. +- **`selectedField` = "hero.cta"** (dotted path) → traverses; each segment can drill into nested groups or follow references. + +This means the section's own canvas always shows **one empty placeholder** (no data exists standalone). That's not a bug; the section only fills out at template-rendering time. (See `build-section` for the UI-side framing.) + +### Where `selectedField` is read from — three sources, in priority order (P23) + +`collect-data-needs.ts` (runtime) and `sectionInstanceBindings.ts` (editor) BOTH read in this order: + +1. **`node.metadata.sectionBindingOverride.selectedField`** — set on the template's `section-composition` node when API-placing the section +2. **`node.metadata.selectedField`** — legacy fallback on the same node +3. **The section's `linked_schemas` default** — declared on the section composition itself: `[{ content_type_uid: "", selected_field: "" }]` + +The Studio web UI sets all three correctly when you drag a section onto a template. API authors set them explicitly. + +### The `linked_schemas`-as-reference trap (P23) + +The compositions CT's `linked_schemas` MUST be a **group-multiple** field, NOT a reference field. If it's a reference field, the CMA **silently drops** any `{content_type_uid, selected_field}` object you `PUT` to it — every read comes back `linked_schemas: []`. The editor reads `linked_schemas` to derive the `UPDATE_SECTION_CONTEXTS` push that scopes each placed section instance; an empty `linked_schemas` means an empty scope push, meaning the editor canvas shows blank repeaters even though `fetchCompositionData` (runtime) renders correctly via the override. + +Two outcomes from this: + +- **Proper fix:** model `linked_schemas` as a group-multiple on the compositions CT — see `provision-studio-project` for the CT field schema. This makes every UI-driven path work without manual overrides. +- **Runtime escape hatch (if you can't change the CT shape right now):** set `metadata.sectionBindingOverride` on each template's `section-composition` node: + ```jsonc + { + "type": "section-composition", + "uid": "INST-1", + "metadata": { + "compositionUID": "", + "sectionBindingOverride": { "selectedField": "products" } + } + } + ``` + This makes the runtime/SSR path correct (`collect-data-needs.ts` honors it). The editor canvas will still show blank repeaters until `linked_schemas` is also populated. So this escape hatch covers production rendering but doesn't unblock editor authoring — both must agree. + +### The editor vs runtime asymmetry (P23, in detail) + +| Path | What it reads | Source file | +|---|---|---| +| **Runtime / SSR** (`sdk.fetchCompositionData`) | `sectionBindingOverride.selectedField` → `metadata.selectedField` → `linked_schemas` default | `collect-data-needs.ts` | +| **Editor canvas** (live editing in Studio web app) | `UPDATE_SECTION_CONTEXTS` push from the Studio web app, derived from the section's `linked_schemas` field. `sectionBindingOverride` is also honored as an override on top. | `sectionInstanceBindings.ts` + Studio web app messaging | + +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 (P24) + +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: {} } }`**: + +```jsonc +{ + "type": "repeater", + "uid": "R-section-products", + "metadata": { "mode": "preview" }, + "props": { + "items": { + "binding": { + "type": "template", + "value": { "path": {} } // ← scope-root, NOT { products: {} } + } + } + }, + "slots": { + "": [ + // ConditionBlock + card here, per the multi-reference recipe; + // card bindings still use type:"repeater" with repeaterUID + ] + } +} +``` + +`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"]`). 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 (P25) + +| 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 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 → `selectedField` + scope-root; many fields read → whole-entry, original paths. + +### Verifying a section's scope headlessly (P26 preview) + +The section's own canvas can't confirm binding correctness (no data exists standalone). Use the SSR cold-load instead: + +```js +const spec = await sdk.fetchCompositionData({ url: "/" }); +console.log(JSON.stringify(spec.data.section_scoped_data[""], null, 2)); +// → { selectedField: "products", parentRepeaterUID: null, +// template: [, , ...] } +``` + +`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. + +## Authoring a Template — populate `linked_sections` for every API-placed section (P27) + +Sibling concern to P22-P25 (which were section-side). When a Template's `ui` tree contains `section-composition` nodes that were placed via API, the template's **`linked_sections` reference field** ALSO has to be populated — even though SSR walks the nodes directly. Same editor-vs-runtime asymmetry as P23 but at the template layer. + +### The asymmetry + +| Path | How it discovers placed sections | +|---|---| +| **Editor canvas** (template open in Studio web app) | Loads the template with `?include[]=linked_sections` (resolves the references inline), builds `spec.sectionCompositions` from the resolved `linked_sections` field. If empty → no section specs → **"Template Did Not Load"** | +| **SSR / runtime** (`sdk.fetchCompositionData`) | Walks the template's `ui` tree looking for `section-composition` nodes; reads each node's `metadata.compositionUID`; fetches each section composition separately | + +So a template can render perfectly on the deployed site (SSR walks the nodes) yet refuse to open in the editor (`linked_sections` is empty, editor has no specs to resolve the nodes against). **"Template Did Not Load"** in the editor + a passing SSR render = empty `linked_sections`. + +### Recipe — populate `linked_sections` when API-placing sections + +When your composition-authoring script writes `section-composition` nodes into a template's `ui`, ALSO populate the template entry's `linked_sections` field with a reference per placed section's composition entry: + +```jsonc +// Template composition entry (CMA write) +{ + "title": "Product Page", + "url": "/products/{{entry.title}}", + "composable_uid": "product_page_template", + "connected_content_type": "product", + "place_composition_as": "page", + "ui": "zlib:", + "linked_sections": [ + { + "uid": "", + "_content_type_uid": "" // the project's compositions CT + }, + { + "uid": "", + "_content_type_uid": "" + } + // …one reference per distinct placed section. Dedupe: a section placed + // multiple times in the same template only needs one entry here. + ] +} +``` + +The references can be bare `{uid, _content_type_uid}` stubs — the CMA stores them; the editor's `?include[]=` resolves them inline when loading. There's no UI-only metadata to mirror (unlike P23's `selectedField`). + +Then **publish the template entry**. The editor reads from the published entry; an unpublished update won't show up. + +### Pre-flight check for an API-authored template + +Before you mark a template as ready, verify both paths agree: + +```bash +# 1. Editor path — does linked_sections resolve to actual sections? +curl -s "https:///v3/content_types//entries/?include[]=linked_sections" \ + -H "api_key: " -H "access_token: " \ + | jq '.entry.linked_sections | length, .entry.linked_sections[0].uid' + +# 2. SSR path — does fetchCompositionData find the same sections? +# (Use verify-setup Layer 7's script and dump section_scoped_data; the key count +# should equal the count of distinct section-composition nodes in the template's ui.) +``` + +Both must agree: +- If editor path returns 0 sections but SSR path returns N → `linked_sections` not populated; populate per the recipe above. +- If editor returns N sections but SSR returns 0 → `linked_sections` is set but the `ui` tree has no `section-composition` nodes (probably a separate authoring bug). +- If both return matching N → both paths are wired correctly. + +## `section-slot` node shape — the one legitimate empty-slots node + +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: + +```jsonc +{ + "type": "section-slot", + "uid": "SS1", + "props": {}, + "slots": { "SS1": [] } // ← key is the node's OWN uid; value is [] until filled +} +``` + +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 the ONE legitimate exception to the preflight rule "no empty slot arrays". Omitting the entry (no `slots` at all, or `slots: {}`) → the template can't 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 carries a `sectionSlotCompositions` map (Pattern B recipe below) that pairs each section-slot uid with the composable_uid of the fill section. + +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). + +## Recipe — Pattern B (wrapper + `section-slot`, filled by a Simple Section) + +Sibling to the multi-reference recipe. Same iteration outcome (one card per reference / MB item), but the leaf content lives in a **separate section composition** rather than in the wrapper's own `ui`. Verified end-to-end for references (Case 8) and modular blocks (Case 9) in `docs/api/sections.md`. + +**When to prefer Pattern B over Pattern A (self-contained):** +- Leaf content is reused across multiple wrappers (e.g. an author card shown in blog lists AND author-page hero). +- Leaf content varies per CT and you want each CT's binding tree in its own composition entry. +- You want to swap the fill section per template instance without editing the wrapper. + +**Shape overview:** + +| Composition | Kind | Role | +|---|---|---| +| Wrapper section | **List Section** — Repeater at root + CB per CT/block, CB slot holds a `section-slot` node | Iterates the reference/MB, emits one slot per item | +| Fill section (one per branch) | **Simple Section** — no root Repeater, `linked_schemas.selected_field` scoped to the reference target CT (or block) | Binds leaf fields | +| Template | Has a `section-composition` node embedding the wrapper; its `sectionSlotCompositions` pairs the wrapper's section-slot uid with each fill section's `composable_uid` | Wires slot ↔ fill | + +### Wrapper section — Repeater + CB + section-slot + +```jsonc +// ui tree of the wrapper section +{ + "type": "repeater", "uid": "R1", + "metadata": { "mode": "preview" }, + "props": { "items": { "binding": { "type": "template", "value": { "path": {} } } } }, + "slots": { "": [ + { "type": "condition-block", "uid": "CB1", + "metadata": { "condition": { "type": "reference", "value": "author" } }, + "slots": { "": [ + { "type": "section-slot", "uid": "SS1", + "slots": { "SS1": [] } } + ] } } + ] } } +``` + +Wrapper section entry also declares `linked_schemas: [{ content_type_uid: "", selected_field: "" }]` (group-multiple field — see P23). + +### 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) +{ + "type": "author-card", "uid": "AC1", + "props": { + "name": { "binding": { "type": "template", "value": { "path": { "name": {} } } } }, + "photo": { "binding": { "type": "template", "value": { "path": { "photo": { "url": {} } } } } } + } +} +``` + +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 — `section-composition` node + `sectionSlotCompositions` + +The template's `section-composition` node embedding the wrapper carries a `sectionSlotCompositions` map keyed by section-slot uid → fill section's `composable_uid`: + +```jsonc +{ + "type": "section-composition", + "uid": "INST-1", + "metadata": { + "compositionUID": "", + "sectionSlotCompositions": { + "SS1": { "compositionUID": "" } + // add one entry per CB branch when references allow multiple CTs; each + // branch's section-slot gets its own uid, each paired with its own fill + } + } +} +``` + +Also populate the template's `linked_sections` (P27) with references to BOTH the wrapper section entry AND every distinct fill section entry. + +### Assembled — reference-iterating wrapper filled by an author fill + +``` +Wrapper section linked_schemas: [{ ct: "blog_post", selected_field: "primary_author" }] + ui: Repeater(items:{path:{}}, mode:"preview") + └── ConditionBlock(condition:{type:"reference", value:"author"}) + └── section-slot uid:"SS1" slots:{"SS1":[]} + +Author fill section linked_schemas: [{ ct: "blog_post", selected_field: "primary_author" }] + ui: author-card + ├── name: binding template path:{name:{}} + └── photo: binding template path:{photo:{url:{}}} + +Template (blog_post) + data_sources: [{ uid:"template", resolvedReferences:{ "template":["primary_author"] } }] + linked_sections: [wrapper-entry-ref, author-fill-entry-ref] + ui: page → … → section-composition uid:"INST-1" + metadata.compositionUID: "" + metadata.sectionSlotCompositions.SS1.compositionUID: "" +``` + +MB variant (Case 9): swap the CB discriminator to `{ type: "modular_block", value: "" }`; wrapper's `selected_field` names the MB field; fill section's `selected_field` names the same MB path plus the block key; leaf bindings inside the fill section still use `type: "template"`. + +## Recipe — Exposed section props (per-instance override) + +Some sections need to expose a prop the **template** can override per drop instance — a Card Grid whose column count is set by the page, or a Hero whose tone changes per template. Verified end-to-end (Case 14 in `docs/api/sections.md`). + +**Two-sided shape** — section declares the exposure; template supplies the per-instance value. + +### Section side — `ui.metadata.sectionExposedProps` at the ui ROOT + +The exposed-props declaration lives on the section's `ui.metadata` (NOT on the individual node). Each entry names one prop on one node, gives it a stable exposure `uid`, and captures the binding at expose time as a fallback: + +```jsonc +{ + "type": "page", + "uid": "", + "metadata": { + "sectionExposedProps": [ + { + "nodeUid": "CARD1", // the node inside the section that owns the prop + "propKey": "tone", // the prop being exposed + "uid": "exp-tone-1", // stable identifier — templates reference this + "displayName": "Card tone", // label shown on the template's right panel + "propType": "choice", // the widget kind — matches props..type + "bindingAtExposeTime": { // fallback binding if the template doesn't override + "type": "static_value", + "value": "CARD1-tone" + } + } + ] + }, + "slots": { /* … normal section ui … */ } +} +``` + +`sectionExposedProps` is on the section's `ui` **ROOT node's `metadata`**, not on the composition entry, and not on the individual node the prop lives on. Getting the placement wrong (on the node, or on the entry) → the template's right panel doesn't render the field. + +### Template side — `section-composition.props.` per instance + +The template's `section-composition` node carries a `props` map keyed by the exposure `uid`: + +```jsonc +{ + "type": "section-composition", + "uid": "INST-1", + "metadata": { + "compositionUID": "", + "sectionBindingOverride": { "selectedField": "…" } // (P23 — separate concern) + }, + "props": { + "exp-tone-1": { // keyed by section's exposure uid + "type": "choice", // must match section's propType + "binding": { + "type": "static_value", + "value": "INST-1-tone-override" // static_value key on the TEMPLATE entry + } + } + } +} +``` + +Two things to internalize: + +1. **Per-instance uid** — the same section dropped twice on one template = two `section-composition` nodes with **different `uid`s**. Each carries its own `props.` map, so each drop can have a different override value. +2. **Static-value keys live on the template entry**, not the section entry. When the override is a static_value, the `binding.value` key is looked up in the **template**'s `static_value.[]` bucket (per the two-level lookup rule earlier). + +### Fallback resolution + +For each exposed prop the SDK resolves in this order at render time: + +1. Template's `section-composition.props..binding` — if present, use it. +2. Section's `ui.metadata.sectionExposedProps[…].bindingAtExposeTime` — fallback. +3. Node's own `props..binding` in the section's `ui` — final fallback. + +So a section can drop with no override (fallback to expose-time binding) OR with a per-instance override (template controls it). Leaving all three unset → the prop renders as the component's `defaultValue`. + +### Verifying an exposed prop + +Same as sections generally — SSR cold-load and dump `spec.data.section_scoped_data[]`. The resolved override appears as the concrete `props.` value on the section instance. If it's the fallback, either the template didn't provide an override or the `exposure-uid` doesn't match. + +## Never pass the compressed `ui` inline via a shell variable + +Silent-corruption trap. The `zlib:` blob is binary-ish; interpolating it into a `curl -d "..."` string via a shell variable mutates one byte and the SDK renders a blank canvas with **no error anywhere** — `hasSpec: true`, spec present, SSR looks fine, and every child slot silently resolves empty because the key `"children"` was corrupted to `"c1ildren"` (or similar) throughout the tree. Background colours still paint (from `responsiveStyles`), masking the bug until you inspect the raw HTML. + +Always write the full payload to a temp file and POST with `-d @file`: + +```bash +python3 -c "import json,zlib,base64; \ + raw = json.dumps(ui_tree); \ + assert '\"c1ildren\"' not in raw, 'corrupt key'; \ + assert raw.count('\"children\"') >= , 'children count wrong'; \ + compressed = 'zlib:' + base64.b64encode(zlib.compress(raw.encode())).decode(); \ + open('/tmp/entry.json','w').write(json.dumps({'entry': {'ui': compressed, ...}}))" + +curl -X POST "/v3/content_types//entries" \ + -H "api_key: ..." -H "authtoken: ..." \ + -H "Content-Type: application/json" \ + -d @/tmp/entry.json +``` + +The count-assert catches shell corruption **before** compression — once the tree is zlib+base64'd, the corruption is opaque. + +**Non-prod hosts + Python.** Against `*.csnonprod.com` (e.g. dev11), Python's `urllib`/`requests` fail SSL verification (`SSLCertVerificationError: unable to get local issuer certificate`) — the non-prod chain isn't in the default Python CA store, but `curl` reads the system keychain and succeeds. Rule: use `curl` for the CMA POST/PUT itself; use Python only to build/assert the payload. If Python HTTP is unavoidable (dev tooling only, never app code), pass a permissive SSL context: `ctx = ssl.create_default_context(); ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE; urllib.request.urlopen(req, context=ctx)`. + +## Complete example — a real Freeform composition body + +Every field below is what Studio actually stores for a real Freeform composition; use this shape verbatim when hand-authoring one. The tree is a single `page` root → `doc-hero` component → `doc-product-card` in the hero's `"Actions"` slot, all bound with `static_value` keys. + +### The `ui` tree (inflated, before `zlib+base64`) + +```json +{ + "uid": "mZfkYLfEsk28cuV", + "type": "page", + "attrs": {}, + "metadata": {}, + "styles": {}, + "props": { + "children": { "type": "slot", "slot": "lW5KA7MYsHQxdHC" } + }, + "slots": { + "lW5KA7MYsHQxdHC": [ + { + "uid": "mHjnXsgKbBlj_PZ", + "type": "doc-hero", + "attrs": {}, + "metadata": { "slotNames": { "7Opc3nDCi1wwpyy": "Actions" } }, + "styles": { "default": { "classes": [""], "responsiveStyles": { "default": {} } } }, + "props": { + "headline": { "type": "string", "binding": { "type": "static_value", "value": "mHjnXsgKbBlj_PZ-headline" } }, + "subhead": { "type": "string", "binding": { "type": "static_value", "value": "mHjnXsgKbBlj_PZ-subhead" } }, + "cover": { "type": "imageurl", "binding": { "type": "static_value", "value": "mHjnXsgKbBlj_PZ-cover" } }, + "ctaLabel": { "type": "string", "binding": { "type": "static_value", "value": "mHjnXsgKbBlj_PZ-ctaLabel" } }, + "ctaHref": { "type": "href", "binding": { "type": "static_value", "value": "mHjnXsgKbBlj_PZ-ctaHref" } }, + "children": { "type": "slot", "slot": "7Opc3nDCi1wwpyy" } + }, + "slots": { + "7Opc3nDCi1wwpyy": [ + { + "uid": "Wo-7Ly0xHXWsOXQ", + "type": "doc-product-card", + "attrs": {}, + "metadata": {}, + "styles": { "default": { "classes": [""], "responsiveStyles": { "default": {} } } }, + "props": { + "title": { "type": "string", "binding": { "type": "static_value", "value": "Wo-7Ly0xHXWsOXQ-title" } }, + "image": { "type": "imageurl", "binding": { "type": "static_value", "value": "Wo-7Ly0xHXWsOXQ-image" } }, + "price": { "type": "number", "binding": { "type": "static_value", "value": "Wo-7Ly0xHXWsOXQ-price" } }, + "badge": { "type": "choice", "binding": { "type": "static_value", "value": "Wo-7Ly0xHXWsOXQ-badge" } }, + "ctaHref": { "type": "href", "binding": { "type": "static_value", "value": "Wo-7Ly0xHXWsOXQ-ctaHref" } } + }, + "slots": {} + } + ] + } + } + ] + } +} +``` + +Notes on the tree that aren't in the recipe examples above: +- Every node carries `attrs`, `metadata`, `styles`, `props`, `slots` — even when empty (`{}`). Studio writes them; you should too. +- The root's `type` is `"page"` — the built-in Page system component. Freeform / Connected templates wrap their content in this root. +- `props.children.type: "slot"` + `props.children.slot: ""` — declares that this node's children live in the matching key inside `slots`. Same pattern on nested slot-owning components. +- `metadata.slotNames.: ""` — components with named slots (like `doc-hero`'s `"Actions"` slot) register the label here so the canvas shows it. +- Each `props..type` (`"string"`, `"imageurl"`, `"number"`, `"href"`, `"choice"`) is the widget kind. It **must match** the sub-bucket of `entry.static_value` the resolver looks in (see the two-level-lookup rule above). + +### The full CMA POST body + +The `ui` value on the wire is the tree above, JSON-stringified, `zlib.compress`ed, base64-encoded, and prefixed with `zlib:`. The rest of the entry: + +```json +{ + "entry": { + "title": "Spring 2026 Landing", + "composable_uid": "spring_2026_landing", + "connected_content_type": "", + "url": "/campaigns/spring-2026", + "place_composition_as": "page", + "schema_version": "1.0.0", + "data_sources": "[]", + "linked_schemas": [], + "linked_sections": [], + "static_value": { + "text": [ + { "key": "mHjnXsgKbBlj_PZ-headline", "value": "Welcome to Studio" }, + { "key": "mHjnXsgKbBlj_PZ-ctaLabel", "value": "" }, + { "key": "Wo-7Ly0xHXWsOXQ-title", "value": "Studio Pro" } + ], + "textarea": [ + { "key": "mHjnXsgKbBlj_PZ-subhead", "value": "Visual composition for content teams — using the React components your engineering already built." } + ], + "imageurl": [ + { "key": "mHjnXsgKbBlj_PZ-cover", "value": "" }, + { "key": "Wo-7Ly0xHXWsOXQ-image", "value": "" } + ], + "href": [ + { "key": "mHjnXsgKbBlj_PZ-ctaHref", "value": "" }, + { "key": "Wo-7Ly0xHXWsOXQ-ctaHref", "value": "https://example.com" } + ], + "number": [ + { "key": "Wo-7Ly0xHXWsOXQ-price", "value": 49 } + ], + "choice": [ + { "key": "Wo-7Ly0xHXWsOXQ-badge", "value": [] } + ], + "array": [], + "boolean": [], + "any": [], + "datestring":[], + "json_rte": [], + "html_rte": [], + "object": [], + "ui": "zlib:eNqtlFtvgjAUgP9L…" + } + } +} +``` + +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 + +Paste into a `.py` file, fill the two `<…>` placeholders, run against a fresh entry uid: + +```python +import base64, json, urllib.parse, zlib + +UI_TREE = { ...the tree from § "The ui tree" above... } +COMPOSITION_ENTRY_UID = "<15-char CMA entry uid you'll POST to>" # optional if using composable_uid slug + +raw = json.dumps(UI_TREE, separators=(",", ":")) +assert raw.count('"children"') >= 2, "children key corrupted before compression" +compressed = "zlib:" + base64.b64encode(zlib.compress(raw.encode())).decode() + +body = { + "entry": { + "title": "Spring 2026 Landing", + "composable_uid": "spring_2026_landing", + "connected_content_type": "", + "url": "/campaigns/spring-2026", + "place_composition_as": "page", + "schema_version": "1.0.0", + "data_sources": "[]", + "linked_schemas": [], + "linked_sections": [], + "static_value": { + "text": [{"key": "mHjnXsgKbBlj_PZ-headline", "value": "Welcome to Studio"}, ...], + "textarea": [{"key": "mHjnXsgKbBlj_PZ-subhead", "value": "…"}], + "imageurl": [{"key": "mHjnXsgKbBlj_PZ-cover", "value": ""}, ...], + "href": [{"key": "mHjnXsgKbBlj_PZ-ctaHref", "value": ""}, ...], + "number": [{"key": "Wo-7Ly0xHXWsOXQ-price", "value": 49}], + "choice": [{"key": "Wo-7Ly0xHXWsOXQ-badge", "value": []}], + "array": [], "boolean": [], "any": [], "datestring": [], + "json_rte": [], "html_rte": [], "object": [], + }, + "ui": compressed, + } +} + +open("/tmp/entry.json", "w").write(json.dumps(body)) +``` + +Then POST with `curl -d @file` (never `-d "..."` — see § *Never pass the compressed `ui` inline*): + +```bash +curl -X POST "https:///v3/content_types/documentation_compositions/entries" \ + -H "api_key: " -H "authtoken: " \ + -H "Content-Type: application/json" \ + -d @/tmp/entry.json +``` + +Publish the entry after create — see § *Sanity-check the output* below. + +## Preflight — validate the `ui` tree before publishing + +The SDK's runtime renderer walks the composition node tree and throws on certain malformed shapes; an unpublishable error reaches the editor BEFORE you see it (`TypeError: Cannot read properties of undefined (reading 'slots')` + `Cannot find a descendant at path [...] in node`), and the entire composition's render aborts — every section after the throw goes blank. The most common cause is an empty Section Slot. Skill's duty is to **never emit one** in the first place. + +Before calling `POST /v3/.../entries` (or `PUT` on update), walk the inflated `ui` tree and assert: + +- [ ] **Every `slots[]` value is a non-empty array** — no `{ "": [] }` entries. Either populate the slot with at least one node, OR remove the slot from the parent's `slots` object entirely. +- [ ] **No node references a removed descendant** — every uid mentioned in a parent's `slots` array maps to an actual node in the tree. (Common when refactoring: removing a node but leaving its uid in a parent's slot array.) +- [ ] **Every Repeater has `metadata.mode: "preview"` AND a Condition Block child (for reference / modular block iteration).** Missing either silently renders zero iterations. +- [ ] **Every Section Slot inside the iteration has its child Section's node in place** — see `build-repeating-section` § *Step 6*. + +Cross-check matches the renderer-side symptom row in [`troubleshoot-canvas`](../troubleshoot-canvas/SKILL.md) (the "Whole composition renders blank or partially blank" row). The runtime crash should never reach the user — that's a Studio bug, but emitting a clean tree avoids it deterministically. + +If publishing through a script, gate the publish call behind these assertions and fail loudly. A composition with an empty slot is invalid; do not emit one. + +## Acceptance + +This skill succeeds only when ALL are true. + +- [ ] The composition entry was created with `composable_uid` = the entry uid, published. +- [ ] `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. +- [ ] Card / iteration child bindings use `type: "repeater"` with `repeaterUID` — NOT `type: "template"` with `.0.` fixed-index paths. +- [ ] **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 (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 `metadata.sectionSlotCompositions..compositionUID`; template's `linked_sections` includes BOTH wrapper and fill entries. +- [ ] **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. +- [ ] The composition resolves at runtime — verified by either curl against the CDA preview API (returns 200 + entries body, see `troubleshoot-canvas` § *CORS error on the CDA preview host*) OR a SSR cold-load `await sdk.fetchCompositionData({ url })` returning `spec.data.section_scoped_data[]` with the expected `template` array (P26-style verification). + +## Common pitfalls + +| Pitfall | Symptom | Fix | +|---|---|---| +| Missing `data_sources.resolvedReferences` | Repeater renders 0 items — reference stubs never materialise | Add the resolution map entry — `{ uid:"template", resolvedReferences: { "root.": [""] } }` | +| 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) | +| `composable_uid` not equal to the CMA entry uid | "Composition Not Found" — canvas link uses entry uid, resolution uses `composable_uid` | Set `composable_uid` = the entry uid before publishing | +| Authored composition is unpublished | Not visible in compositions list; can't load in canvas | Publish the entry; Studio queries by `composable_uid` AND published state | +| `linked_schemas` modeled as a reference field on the compositions CT instead of group-multiple | Section's `selectedField` derived from `linked_schemas` is always empty → section scope is unset → blank repeaters in the editor (P23). Runtime may still work via `sectionBindingOverride`. | Ensure compositions CT models `linked_schemas` as a group-multiple — see `provision-studio-project` | +| Treating this skill as a UI-authoring guide | Wastes time; introduces shape inconsistencies | Use only for headless / scripted / seed / migration work. For interactive authoring, the Data Picker writes every shape correctly. | +| Placing bindings directly on the node instead of `props..binding` | The SDK can't find the binding → prop renders with `defaultValue` or empty | Always wrap: `"props": { "": { "binding": { type:…, value:{…} } } }`. | +| Using `children: [...]` instead of `slots: { : [...] }` for child nodes | Children invisible to the renderer; canvas renders the parent as empty | Use `slots`; each slot has a uid. See node anatomy above. | +| 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`. | +| 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. | +| 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 nested-`.0.` family of the P19 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 (P23) | 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: {} }` (P24) | 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`. | +| 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 (P27) | **"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"` → `"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 `*.csnonprod.com` (dev11 etc.) | `SSLCertVerificationError: unable to get local issuer certificate` — the non-prod 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 (dev tooling), pass a permissive `ssl.create_default_context()` with `check_hostname=False` + `verify_mode=CERT_NONE`. Never do this in app code | +| `section-slot` node written without `slots: { "": [] }` (empty-array entry keyed by the node's own uid) | Template's `sectionSlotCompositions` has no target to fill — 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* | +| 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 template drops the section-composition without a `sectionSlotCompositions` map | Wrapper renders its Repeater + CB but each iteration's section-slot has no fill — placeholder shows blank | Populate `metadata.sectionSlotCompositions..compositionUID` with each fill section's `composable_uid` on the template's `section-composition` node. 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* | +| Template's `section-composition.props.` key uses the section's node uid or prop key instead of the exposure `uid` | Override never matches — SDK looks up by exposure `uid`; unmatched → falls back to `bindingAtExposeTime` → looks like the section is ignoring the template | Key the template's `section-composition.props` map by the `uid` field from the section's `sectionExposedProps` entry (e.g. `"exp-tone-1"`), NOT by nodeUid or propKey | +| Same section dropped twice on a template shares one override value | Both instances render with the same override — user expected per-instance control | Each drop is a separate `section-composition` node with its own `uid`; each carries its own `props.` map. Static-value keys should also differ per instance (e.g. `INST-1-tone`, `INST-2-tone`) so the template's `static_value.` bucket stores each independently | +| API-authoring a `json_rte` embedded-entry `reference` node with `attrs` missing `locale` | CMA rejects with `"Reference must contain content-type-uid, entry-uid, locale and display-type."` at write time. SDK read path doesn't need `locale` (derived from context) so this only surfaces on POST/PUT | Every embed reference node needs `attrs: { type: "entry", "entry-uid": "…", "content-type-uid": "…", "display-type": "block"\|"inline"\|"link", "locale": "en-us" }`. Applies to both node-level `json_rte` prop values and `static_value.json_rte[*].value` embeds. See [`register-json-rte`](../register-json-rte/SKILL.md) pitfall row | + +## Sanity-check the output + +After authoring, verify by ONE of: + +1. **CDA preview API curl** — confirm the composition is returned. See `troubleshoot-canvas` § *CORS error on the CDA preview host* for the curl shape. Status 200 + JSON entries body = the contract resolves. +2. **SSR cold-load** — `await sdk.fetchCompositionData({ url })` from a throwaway server route. Dump `spec.data.section_scoped_data[]` to verify each section's `selectedField` + scoped `template` arrives correctly. The most reliable check. +3. **Decode the stored `ui`** — the read-only `zlib`-inflate snippet in `troubleshoot-canvas` § *Diagnostic tooling*. Diff your composition's node tree against a known-working UI-authored one to spot any shape mismatch. Useful for debugging. + +## See also + +- `troubleshoot-canvas` § *Diagnostic tooling — inspecting a stored composition's ui* — the zlib decoder +- `troubleshoot-canvas` § *CORS error on the CDA preview host* — preview-token verification curl shape +- `use-repeater` — UI authoring side; the Preview Mode toggle the API directly sets via `metadata.mode` +- `use-condition-block` — the Condition Block schema requirement for reference / modular-block iteration +- `build-section` — section's `selectedField` scoping (the parent of most API-authored sections) +- `understand-auto-binding` — the UI mechanism that writes these shapes for UI authors +- `provision-studio-project` (planned) — compositions CT field schema, including `linked_schemas` as group-multiple +- `troubleshoot-data-binding` — the "registered as lazy but not loaded yet" error class diff --git a/codex/author-without-code/SKILL.md b/codex/author-without-code/SKILL.md new file mode 100644 index 0000000..6662e6e --- /dev/null +++ b/codex/author-without-code/SKILL.md @@ -0,0 +1,107 @@ +# author-without-code + + +## When to use + +End-to-end authoring path for content teams on a Studio project a developer has already set up — pick a template, bind data, preview, save, publish — no code required. + +Use when the user wants to compose pages WITHOUT engineering involvement — pure UI authoring against an already-installed Studio project. Phrases — "build pages in Studio", "I just want to compose", "no code". Do NOT use for installs (`install-studio`), registration (`register-component`), or troubleshooting — those are engineering tasks. + +# Author a page in Studio — no code required + +## Context + +Studio is split into two surfaces, and confusing them is the #1 reason content authors stall. + +1. **Contentstack** — where the **content** lives (entries, assets, content types). This is the CMS you already know. URL: `app.contentstack.com/.../stacks/...`. +2. **Studio** — where the **layout** lives (which sections, in what order, bound to which fields). URL: `app.contentstack.com/.../projects/...`. + +A published page combines layout (Studio) + content (Contentstack entry) → URL rendered by the developer's app. You move between both surfaces; this skill tells you which surface to use for each step. + +Two kinds of pages exist in Studio, with very different authoring flows: + +| Page kind | What it is | When you'll use it | Where to start | +|---|---|---|---| +| **Connected Template** (the main authoring surface) | One layout, bound to a whole content type. Every entry of that CT renders through this layout at a URL derived from the entry (e.g. `/blog/` for every blog post). Also used for one-off pages backed by a single-entry CT (the homepage, the about page, etc. — your developer may have modelled those as single-entry CTs). | "Add a new blog post." "Add a new product." "Update the homepage hero copy." | **Contentstack** — create or edit the entry in the CT. Studio auto-renders it. | + +**Most content-team work is content-first** — edit the entry in Contentstack, the page updates automatically. Open Studio only when the *layout itself* needs to change. + +## Task + +### 1. Open Studio and orient + +Open the URL the developer shared. You should land in a **Studio project** with two tabs in the top nav: + +- **Compositions** — every page-shaped layout in this project. The page splits into two sub-tabs inside: **Templates** (Connected Templates you'd publish at a URL) and **Sections** (reusable layout blocks the developer prepared, dropped *into* compositions — you do not usually create these directly). This is the main authoring surface. +- **Settings** — project-level config (rarely needed by authors). + +Registered React components don't have a top-level tab — they're visible from the left palette when you have a composition open. There is no UI to create or edit components from inside Studio. + +If the **Templates** sub-tab is empty, stop. Ask the developer to confirm at least one Connected Template has been created. Without a starting point, you cannot author. + +### 2. Decide your path from the goal + +Map your `goal` to one of these three flows. + +- **"Publish a new blog post / new product / new entry of an existing CT"** → Flow A (edit in Contentstack, no Studio needed) +- **"Change the text / image / link on an existing page"** without changing the layout → Flow D (Live Preview or Visual Editor — fastest path, no Studio) +- **"Update the layout of an existing page"** (reorder sections, swap a hero, change a binding) → Flow C (Studio → open existing composition) + +### 3. Run the matching flow + +#### Flow A — New entry of a Connected content type (most common) + +1. Switch to **Contentstack** (same nav, different surface). +2. Open the relevant content type → **+ New Entry**. +3. Fill in **every field** the Connected Template binds. If you skip a field that the template uses, the live page renders that part as empty. Hover the field name to see help text the developer wrote. +4. **Save** the entry (in CS, not Studio). +5. **Publish** the entry to the environment your site reads from (usually "production" — confirm with the developer if unsure). +6. Visit the live URL — the entry should render via the template within seconds (publish latency varies). + +#### Flow C — Edit layout of an existing composition + +1. Studio → **Compositions** tab → click the row for the page you want to edit. +2. The canvas opens with the page as currently saved/published. The status badge (top right) shows **Draft** if unsaved changes exist, **Published** otherwise. +3. Make changes — drag sections in/out, rebind fields, change order. +4. **Save** → **Deploy**. Save persists the draft in Studio; Deploy makes the live URL render the new layout. + +#### Flow D — Just change content (text / image / link) on an existing page + +For a Connected template page: it's a Contentstack entry edit. Flow A, but on an existing entry — open the entry → edit fields → **Update** → **Publish** (Contentstack's own publish action, not Studio's Deploy). + +If you don't know whether a field is bound or static: open the composition in Studio, click the field on the canvas, and read the Properties panel. It will say either "Static" (Flow C edit) or show a binding chip pointing to an entry (Flow A-style edit on that entry). + +### 4. Verify before stepping away + +Open the live URL in an **incognito / private browser window** (so you're not seeing a cached or preview version) on the production environment. Check: + +- The page loads at the URL you expect. +- All content is filled — no `[unbound]`, no `{placeholder}`, no broken images. +- Links work. +- Mobile width looks OK (resize the window). + +If any of these fail, return to the relevant flow. Don't tell the team "it's live" until you've checked the live URL in incognito. + +### 5. Bookmark where to come back + +Bookmark the entry in Contentstack — that's your edit point forever. If you also need to change the layout later, bookmark the composition in Studio too. + +## When to escalate to the developer + +Stop and ask the developer rather than guessing if any of these are true: + +- The Compositions tab is empty, or no template covers your CT. +- The section you need does not exist in the Sections palette. +- A field you need to bind isn't on the entry (the content type needs a new field — a CMS schema change). +- You see `[unbound]` after binding and don't know why. +- Publishing succeeds but the live URL still shows the old version after 5+ minutes. +- The URL pattern you need conflicts with another composition's pattern (you'll see a warning in Studio). + +These are not author-fixable — they need developer changes or are bugs. + +## What this skill is NOT + +- Not a guide to set up Studio (use `install-studio` / `studio-tour`). +- Not a guide to register components (use `register-component` — developer task). +- Not a guide to building sections (use `build-section` — typically a developer / designer task). +- Not how Studio works internally (use `studio-tour` for that). diff --git a/codex/build-connected-template/SKILL.md b/codex/build-connected-template/SKILL.md new file mode 100644 index 0000000..2906564 --- /dev/null +++ b/codex/build-connected-template/SKILL.md @@ -0,0 +1,216 @@ +# build-connected-template + + +## When to use + +Scaffold a Connected (content-type-bound) Studio template by dropping sections, wiring linked-schema bindings, and saving — every entry of that CT renders at a derived URL. + +Use for a Connected template — a page bound 1:1 to a content type (blog post, product) where every entry renders at a derived URL. Phrases — "build a blog template", "PDP template", "connected to a content type". Assumes `install-studio` is done. + +# Build a Connected Template in Studio + +## STOP — apply the Section-qualification rule before dropping anything + +Before dropping components or bindings directly on this Template, check the **golden rule** from [`plan-studio-architecture`](../plan-studio-architecture/SKILL.md) § *Step 2*: + +- **Reused across Templates?** Any bound compound component that appears on more than one Template MUST be built as a Section first (`build-section` or `build-repeating-section`), then dropped here. +- **Schema iteration (Modular Block / multi-Reference / group-multiple)?** MUST become a **List Section** (Repeater + CBs). Never author the iteration directly on the Template. + +Only when BOTH answers are no is it correct to drop registered components straight onto this Template. When in doubt, build the Section first — Sections are cheap to add, direct-drop is expensive to unwind. + +## Context + +Templates compose Sections — build Sections first via `build-section`. See `understand-templates` § *Templates compose Sections*. + +**Connected is the default flavor** — including one-off pages backed by a single-entry CT. + +A **Connected template** is bound to one content type. Every entry renders through it at a URL derived from the CT's `url` field (or VX preview URL, or per-template override). What makes it Connected: + +1. **Content-type binding** (chip in canvas chrome). +2. **Linked-schema auto-binding** — Studio reads each section's `linked_schemas` and wires it to a matching field on the CT. + +Studio uses one preview entry at a time (auto-picked, most recently updated matching the URL pattern); bindings record field paths, not entry UIDs. + +Two render surfaces: +- **Studio canvas iframe** — `` on the canvas route, for authoring. +- **Live site** — `` on ONE catch-all (`app/[[...slug]]/page.tsx` / ``). Studio resolves every URL via `sdk.fetchCompositionData(...)`; per-template routes are NOT needed. Without the catch-all, the derived URL 404s even after Deploy. + +This skill creates the template, drops sections, confirms bindings, saves. It does NOT publish/deploy. + +## Task + +1. **Confirm prerequisites.** Before touching the UI, verify (ask the user to confirm if you can't check): + - Studio project exists and is linked to the stack. + - Content type `contentTypeUid` exists and has at least one entry. + - **A URL pattern is available.** Studio resolves which entry to preview by matching the request path against a URL pattern. **One of these five sources must be in play**: `custom_preview_url` (Stack → Visual Experience), `content_type_url_pattern` (page CT with `url_pattern`), `default_url_pattern` (Studio-generated default — applied automatically for connected templates when nothing else is configured), `user_specified_pattern` (authored via the canvas's Edit URL flow), or `legacy_url` (binding against a CT `url` field on every entry). You do NOT need a CT `url` field — Studio's `default_url_pattern` covers the no-config case. Only verify upfront if the project uses the `legacy_url` path: in that case every entry must have `url` populated AND starting with `/` (no leading-slash → invisible to the matcher; canvas opens blank with "No composition for /…"). + - At least one **published** entry exists for the connected CT. Connected canvas fetches via the CDA; unpublished entries return 404 — canvas appears blank. + - At least one Section composition exists whose `linked_schemas` references a field on `contentTypeUid` — otherwise auto-binding will silently leave every section unbound. If unsure, list sections via the Studio Sections tab and check each one's `linked_schemas`. + - For a custom URL pattern: prefer configuring **Stack → Settings → Visual Experience → Preview URL** rather than per-template overrides. If the CT already has a `url` field with a pattern, Studio will derive from it automatically. + +2. **Open the Templates tab** in the target Studio project. Empty state shows an illustration with a **+ New Template** button; populated state shows a list with columns *Title · Connected Content Type · Publish Status · Modified At · Actions*. + +3. **Click `+ New Template`** (top right). The *Create New Template* chooser modal opens with the Connected Template option ready to create. + +4. **Fill in the modal:** + - Title: `templateName` + - Connected Content Type: pick `contentTypeUid` from the dropdown (lists every CT in the stack) + - Click **Create Connected Template** + +5. **Verify the canvas chrome** once Studio opens the new template: + - Composition title matches `templateName` (top bar) + - Content-type chip matches `contentTypeUid` + - `PREVIEW ENTRY :` *(entry name)* with a `⇄` swap icon — Studio auto-picks the most recently updated entry that satisfies the URL pattern + - URL line directly below evaluates against that entry (no `{{...}}` leftovers) + + **⛔ Symptom — "No entry has a URL field populated yet" (canvas shows the message, no preview entry is auto-selected):** the connected CT either isn't a page type, OR no entry of that CT has its `url` field set. Fix: ensure the CT is `is_page: true` with a `url` field + `url_pattern` (see [URL pattern rule below](#single-entry-vs-multi-entry-url-pattern-rule)); for every entry, set the `url` field; then click `⇄` and pick a preview entry. After a CT `url_pattern` change, re-save + republish entries so `entry.url` recomputes — otherwise old entries still carry stale URLs. + +6. **(Optional) Edit the URL pattern.** Only if `urlPattern` is provided OR the derived URL is wrong. Pencil icon → *Edit URL*. Use Insert chips for `{{entry.}}`, `{{environment}}`, `{{taxonomy:}}`. **Save** validates + refetches. **Avoid `{{locale}}`** — carry locale via routing + SDK `locale` query option. + + **⛔ Single-entry vs multi-entry URL pattern rule.** Connected templates split into two cases that need different URL shapes: + + - **Single-entry template** (homepage, about, pricing — one entry, one URL): a literal URL like `/home` works. The matcher only needs to resolve one path. + - **Multi-entry template** (blog posts, products, recipes — many entries through one template): the literal pattern `/blog/:slug` does NOT work. The `:slug` placeholder comes back **literal** — Contentstack's **CMS-side** CT URL-pattern compiler (separate from the Studio SDK pattern compiler) doesn't substitute `:slug`, the entry's `url` field saves as `/:slug` instead of a real slug, and the SDK has no per-entry URL to match against. **The proven shape:** CT `url_pattern: "/:title"` — Contentstack's CMS slugifies the title field into `entry.url` (`:title` is the working CMS substitution token; `:slug` is treated literal) + CT `url_prefix: "//"` (e.g. `/blog/`); composition `url: "//{{entry.title}}"` with `url_metadata.url_source: "content_type_url_pattern"` and `url_queries: ""`. The SDK then queries entries by `entry.url`. CT pattern and composition URL must be **consistent by construction**. After changing the CT pattern, re-save + republish every entry so `entry.url` recomputes. + + **⛔ URL traps — same symptom ("No composition for /…"), different fixes:** + + - **(a) Literal URL on a multi-entry template.** A composition `url` like `/home` works for single-entry, but a multi-entry template needs a placeholder. **Fix:** use `{{entry.url}}` or `{{entry.title}}` (NOT `:slug` — see rule above). + - **(b) Entry `url` field missing leading slash.** SDK matches the full request path; `url: "home"` never matches `/home`. **Fix:** every entry's `url` starts with `/`. + - **(c) API-set `user_specified_pattern` reverts.** `url_metadata.url_queries` is only generated by the UI Edit-URL → Save flow. **Zero-UI workaround:** make the CT a page type (`is_page: true` + CT-level `url_pattern`), set composition `url_metadata.url_source: content_type_url_pattern`, put `{{entry.x}}` in composition `url`. + - **(d) `:slug` placeholder comes back literal.** Contentstack's CT URL pattern recognises `:title` but treats `:slug` as a literal string — `url_pattern: "/:slug"` saves and stays `/:slug`, never substituting. **Fix:** use `:title` in the CT pattern (Contentstack slugifies it); use `{{entry.title}}` in the composition URL. + +7. **Drop the primary section.** If `primarySectionUid` was provided, drag it from the left palette (Sections category) or the Sections sidebar onto the empty canvas. Drop targets highlight as you drag. + +8. **Confirm auto-binding.** When the section drops, Studio reads its `linked_schemas` and tries to bind to a matching field on the connected CT: + - **One match** → bound silently. Verify by selecting the section → right panel → **Settings** tab. + - **Multiple matches** → bound to one; switch via the dropdown in the section's right-panel **Settings** tab. + - **No match** → drops unbound. Either (a) add a matching field to the CT, (b) edit the section's `linked_schemas`, or (c) manually pick a compatible field from the section's Settings if one exists. + For sections that render lists, the section internally uses a Repeater bound to the matched field; per-item field bindings inside the section read from `repeater.` and resolve automatically — no per-template wiring needed. + +9. **Fill any Section Slots.** If the section exposes a Section Slot (e.g. `Hero Strip` slot for a CTA), drag a component (Button, Link, registered component) into the slot's outlined region on the canvas. Slots are carved out of the section's structure — drop into the outlined region, not the surrounding section frame. + +10. **Override exposed props per template instance.** Select the section → right panel → **Settings** tab. Each exposed prop (e.g. *Card Title*) shows an input; type the per-template override (e.g. `"Related Blogs"`). This stores on the template instance, not on the section composition itself. + +11. **(Optional) Drop individual components and bind them.** From the left palette (Basic / Media / Container / Smart Containers / Registered Components / HTML Elements). Select on canvas → right panel → **Data** tab → click the binding chip on a prop → Data Picker opens → pick `entry.`, a reference field, or a context value (including `repeater.` when inside a Repeater). + +12. **Sanity-check with a swap.** Click `⇄` next to PREVIEW ENTRY and pick an entry with different content shapes (long titles, missing images, empty references). Canvas re-renders instantly. Empty fields mean the *entry* lacks the value — not that the binding is wrong. Don't re-edit the binding to "fix" an empty render; swap back and confirm. + +13. **Save.** Top bar **Save** persists the template to Contentstack. This does NOT publish — Deploy is a separate step that moves the template through the publishing workflow to an environment. + +14. **Smoke-test on the live site.** Hit the derived URL on the host app. The catch-all route should match it and mount ``. If the canvas iframe renders but the live URL doesn't, the catch-all route is missing — add it via [`setup-template-preview-routes`](../setup-template-preview-routes/SKILL.md) (default = ONE catch-all that handles every URL; no per-template route needed) before claiming success. The live page fetches its data via `sdk.fetchCompositionData(...)` under the hood. + +15. **⛔ Connected templates preview at the ENVIRONMENT base URL — NOT the Canvas URL.** + - **Sections** preview at the **Canvas URL** (Studio → Project → Settings → Configuration). + - **Connected templates** preview at the **environment base URL** + resolved path (`Stack → Settings → Environments → → Base URL`). + + If sections render but a connected template shows *"localhost didn't send any data"* / *"SDK Not Initialized"*, env base URL is wrong (common cause: scheme mismatch — env says `http://localhost:5173` but dev server is HTTPS-only via mkcert). + + **Fix:** `PUT /v3/environments/` so `urls[...]` scheme matches the served canvas-app. + +16. **⛔ Multiple-file fields bind to `.0.url`, not the field name.** When a CT has a `file` field of type *multiple* (an asset array), and you bind it to a scalar prop like `imageurl`: + + - Wrong: `entryBind("imageurl", "images")` — binds the array → renders "No image" (or the array's `.toString()`). + - Right: `entryBind("imageurl", "images.0.url")` — picks the first asset's URL. + + If the prop should iterate (multiple images), use a **Repeater** bound to the array and bind `imageurl` to `repeater.url` inside the repeater item. The `.0.url` shortcut is for single-image props only. + +## Inputs needed from the user + +Ask in this order. Stop if any required input is missing. + +1. `contentTypeUid` — content type uid (required) +2. `templateName` — human title (required) +3. `urlPattern` — optional override; leave blank to use CT-derived URL +4. `primarySectionUid` — optional; leave blank to skip auto-drop and let the user choose interactively + +If the user doesn't know the content-type UID, point them at: `app.contentstack.com` → stack → **Content Models** → click a model → the UID is on the right-rail. + +## Acceptance + +Succeed only when ALL of the following are true. If any fails, surface exactly which step broke and stop. + +- [ ] A new row appears in the Studio Templates list with `templateName` as title and `contentTypeUid` populated in the *Connected Content Type* column +- [ ] The canvas opens cleanly on reload — preview-entry URL line evaluates without `{{...}}` leftovers +- [ ] Every section dropped is either bound (linked-schema match shown in right panel) or explicitly flagged unbound with the reason +- [ ] Bound props render real values from the auto-picked preview entry; swapping the preview entry re-renders without errors +- [ ] Save completed without error (top bar reverts from dirty to clean state) +- [ ] Hitting the derived URL on the host app's catch-all route renders the page (404 → the catch-all is missing or the URL pattern doesn't match what Studio expects; do not claim success) + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| "Template did not load." | URL pattern doesn't match anything the host app's catch-all routes through Studio | Fix the pattern or wire the catch-all — see template preview routes. Per-template routes are NOT the fix; the catch-all covers every URL. | +| Bound value renders empty | Current preview entry lacks that field | Swap via `⇄`, don't re-edit the binding | +| Registered component drops blank | Missing default data on the component | Developer needs to add Component Default Data; not a Studio-side fix | +| Section drops unbound | No `linked_schemas` match on the CT | Add a matching field, edit the section's `linked_schemas`, or bind manually from Settings | +| "No element is currently selected" in the right panel | Nothing on canvas is selected | Click the component on canvas first | +| Editing URL from the composition list | That view is read-only for URLs | Edit from the canvas navbar pencil icon | +| Assuming Save with preview entry = X pins the template to X | Bindings record field paths; preview entry is authoring-only | Treat preview-entry swaps as visual diagnostics, not state | +| Dropping a Rows / Box wrapper before the first real component | Leaves visible "Drop Here" placeholder zones in canvas + screenshots. Rows is an e2e-test scaffolding habit, NOT a Studio authoring pattern. | Drop the first section directly onto the canvas root slot. Wrap in a container ONLY for explicit layout (e.g. `hstack` for two cards side-by-side). | +| Adding a Repeater for a non-list use case | Repeater iterates a multi-valued field; single-hero + single-body needs zero iteration | Use Repeater ONLY when the field is genuinely a list (Modular Block list, multi-Group, multi-Reference). For one-of-each: drop directly. | +| Leaving empty "Drop Here" zones at the bottom | Saved compositions render those zones as visible placeholders in screenshots and at runtime in builder mode | Before Save, switch to Layers and delete any orphan empty Box / Slot rows | + + + +## LLM execution caveat — drag-drop works, but only with the right sequence + +Studio's canvas is a React-DnD iframe listening on `mousedown` / `mousemove` / `mouseup` (NOT HTML5 native drag). Use `page.mouse.down/move/up` directly — `dragTo()` fires HTML5 events Studio doesn't honor. + +**Stable selectors:** + +- Palette tile: `[data-builder-component="true"][data-node-type=""]` (Section tiles use the section's composition UID) +- Canvas iframe: `[data-testid="canvas-iframe"]` +- Drop slot: `[data-composable-studio-slot="true"]` (the `="true"` filter is required) +- Layers row: `[data-testid="layer-editable-title-container"]` +- Node IDs: `[data-composable-studio-id]` + +**The drop sequence:** + +```ts +const item = page.locator('[data-builder-component="true"][data-node-type="doc-hero"]'); +const frame = page.frameLocator('[data-testid="canvas-iframe"]'); +const slot = frame.locator('[data-composable-studio-slot="true"]').first(); + +await item.hover(); // 1. position cursor over palette tile +await page.mouse.down(); // 2. mousedown → posts PARENT_DRAG_START to iframe +const sb = await slot.boundingBox(); +await page.mouse.move(sb.x + sb.width / 2, // 3. move cursor in STEPS — required for mousemove events to fire + sb.y + sb.height / 2, + { steps: 10 }); +await slot.hover(); // 4. final settle on the slot (FrameLocator handles cross-frame) +await page.mouse.up(); // 5. mouseup → commits the drop +``` + +The `move({steps: 10})` is critical — without intermediate mousemove events the drop is silently swallowed. + +**Anti-phantom guardrail.** Verify a NEW `data-composable-studio-id` appeared after each drop: + +```ts +const idsBefore = await frame.locator('[data-composable-studio-id]') + .evaluateAll(els => els.map(e => e.getAttribute('data-composable-studio-id'))); +// ... drop sequence ... +await page.waitForTimeout(800); +const idsAfter = await frame.locator('[data-composable-studio-id]') + .evaluateAll(els => els.map(e => e.getAttribute('data-composable-studio-id'))); +const newIds = idsAfter.filter(id => !idsBefore.includes(id)); +if (newIds.length === 0) { + throw new Error('Drop did not commit; do not continue.'); +} +``` + +If `newIds.length === 0`: stop and surface the failure. + +**Sibling drops.** Once root is occupied, hover the **edge** of an existing node for a drop indicator, or wrap children in a `box`/`vstack`/`hstack` container. + +**Also works programmatically:** Layers row + `Delete`; Save button; right-panel tab switches; opening Configuration / URL Pattern modals; reading iframe state via `frameLocator`; switching palette accordion sections. + +## See also + +- `docs/31-templates/overview.md` — Templates tab, list columns, create modal +- `docs/31-templates/connected-content-type.md` — URL derivation, preview entry, edit URL modal +- `docs/31-templates/using-sections-and-components-in-a-template.md` — palette categories, auto-binding flow, Save vs Deploy +- `docs/32-sections/auto-binding-by-drop-location.md` — linked-schema matching rules +- `docs/34-smart-containers/section-slots.md` — filling carved-out slots +- `docs/32-sections/expose-section-props.md` — what becomes overridable per template instance +- `docs/90-reference/url-variables-reference.md` — full URL variable list +- Pair with `install-studio` (SDKs) and `setup-template-preview-routes` (host-app **catch-all route** mounting `` — one route handles every URL). diff --git a/codex/build-freeform-template/SKILL.md b/codex/build-freeform-template/SKILL.md new file mode 100644 index 0000000..ac8f125 --- /dev/null +++ b/codex/build-freeform-template/SKILL.md @@ -0,0 +1,188 @@ +# build-freeform-template + + +## When to use + +Author a Freeform (Static) template with Additional Entry Data pins and Pinned Queries. Connected is the default; Freeform is the last resort. + +Use ONLY when the page genuinely cannot be modelled by any content type (not even a single-entry CT). First run `choose-connected-vs-freeform` — if its recommendation is Connected, stop and use `build-connected-template` instead. Reach for this when the page composes from multiple unrelated sources with no anchor CT. Three-AND Freeform rule applies. + +# Build a Freeform template + +## STOP — Freeform is the very very very last resort + +**Reach for Freeform only after confirming ALL THREE of these are true:** + +1. **Short-lived** — the page has an expected end date (campaign, splash, takeover, time-boxed promo). +2. **Owns ZERO content of its own** — no hero copy, no page-specific title, no subheading, no CTA text, no callouts. Literally nothing the page itself authors. Hero copy IS content; "throwaway HTML" is owned content in disguise. +3. **Visible content is 100% assembled from existing entries** — pinned entries, pinned queries, references into existing CTs. The page is a pure vitrine. + +If ANY of those is missing — even a hero title specific to this page — the page has owned content. Owned content belongs in a CT → **Connected** (single-entry CT for one-offs; multi-entry / `campaign` CT for recurring shapes). Most pages users initially tag as Freeform DO own copy and should be Connected. + +**If you arrived here without running `choose-connected-vs-freeform` or `plan-studio-architecture`, stop and run one of those.** This skill exists for genuine vitrine cases — they are rare. + +## Before you start: confirm Freeform is the right choice + +If you haven't already run `choose-connected-vs-freeform`, do that first. Only proceed when every Connected option (existing CT, extended CT, single-entry CT) has been ruled out. The Project-level **Enable Freeform Feature** toggle is often deliberately OFF — if off, usually leave it off and create a CT. + +**Section-qualification rule still applies.** Before dropping components + bindings directly on this Freeform template, apply the golden rule from [`plan-studio-architecture`](../plan-studio-architecture/SKILL.md) § *Step 2*: **reused across Templates?** → Section first. **Schema iteration (MB / multi-ref / group-multiple)?** → List Section first. Freeform doesn't exempt you from this — a shared Header pinned to two Freeform pages must be a Section, and a Pinned-Query Repeater rendering N items belongs in a List Section, not authored inline. + +## Context + +**Freeform** (labeled **Static Template** in the create modal, **FREEFORM MODE** on canvas — same thing) is not tied to a content type. Bring data in by *pinning* entries and queries directly on the template. Use only as the documented exception. + +The right panel (nothing selected) surfaces three sections: + +- **Additional Entry Data** — explicitly chosen single entries; bindable roots in the Data Picker. +- **Pinned Queries** — saved CDA queries; Repeater item sources. +- **External Data** — non-Contentstack sources via Component Default Data at runtime. + +Reference docs: `docs/33-freeform/`. + +## Prerequisite + +Project Configuration → **Enable Freeform Feature** must be ON. Without it, the right panel collapses to **Settings only** — no Design tab, no Data tab, no way to pin. If the user sees only Settings, stop and direct them to flip the feature flag at the project level before continuing. + +## Task + +1. **Confirm Freeform is enabled** on the Studio project. If not, instruct the user to open Project Configuration and toggle **Enable Freeform Feature** on, then resume. + +2. **Create the template.** + - From the Studio templates list, click **New Template**. + - In the *Create New Template* chooser modal, pick **Freeform** (the chooser's two options are Connected Template and Freeform). The follow-up dialog is titled "Create Static Template" — *Static* is Studio's internal name for Freeform; same thing. + - Enter the `templateName` from inputs and confirm. + + On the canvas verify: + - A **FREEFORM MODE** badge in the top bar (replaces the content-type chip used by Connected templates). + - A **PREVIEW COMPOSITION:** label (not **PREVIEW ENTRY:**) — preview drives off the composition itself. + +3. **Edit the URL pattern.** + - Click the **pencil icon** next to the URL in the canvas navbar to open **Edit URL**. + - Replace the auto-generated default with `urlPattern` from inputs. + - The Insert chip row exposes exactly one entry-style variable: `{{composition_uid}}`. There is intentionally no `{{entry.*}}` and no `{{content_type_uid}}` — Freeform has no connected entry/content-type. You may type context vars (`{{environment}}`, `{{branch}}`) by hand. `{{locale}}` is also accepted by the pattern engine but **avoid using it** — the recommended way to carry locale is via your routing layer + the SDK's `locale` query option (see [multi-locale-at-scale](docs/50-advanced/managing-multiple-locales-at-scale.md)). + - Save. + +4. **Open the template-level Data tab.** + - Click empty canvas space to ensure no component is selected (otherwise the right panel switches to component-prop bindings). + - Right panel → **Data** tab → confirm three accordion sections appear: **Additional Entry Data**, **Pinned Queries**, **External Data**. Empty state reads "No Data Connected Yet". + +5. **Pin an entry (Additional Entry Data).** If `pinnedEntryContentTypeUid` was provided: + - In the **Additional Entry Data** section click the empty-state prompt / **Link Entry** button. + - In the entry-picker modal, set the content-type filter to `pinnedEntryContentTypeUid`. + - Use search/sort/checkbox selection in the entries table, then click **Add Selected Entries**. + - The pinned entry now appears with its title + content type and is bindable from any component's Data Picker. + - Re-pinning (swap to a different entry of the same shape) preserves existing bindings. + +6. **Pin a query (Pinned Queries).** If `pinnedQueryContentTypeUid` was provided: + - In the **Pinned Queries** section click **+ New Query**. + - In the **New Query** modal fill **Name** and set **Content Type** = `pinnedQueryContentTypeUid`. + - Either configure filters/order/limit directly, or type a free-text prompt in **"Tell me what data you need"**. Quick-prompt seeds include *"Show the last 5 recent entries"*, *"List all entries from the past week"*, *"Filter the entries by specific tags"*, *"Fetch the entries containing taxonomy"*. The prompt is converted to a stored CDA query at save time — it is not re-translated per render. + - Save. The query appears under **Pinned Queries** with its Name + Content Type. + +7. **Bind components on the canvas.** + - Drop a card, hero, or other component → right panel → **Data** tab → click a prop → the **Data Picker** opens. Pinned entries appear as bindable roots alongside their fields. + - For lists: drop a **Repeater**, bind **Items** to a Pinned Query, then drop the iteration component inside the Repeater body. Bindings inside resolve against the current iteration (`repeater.`). + - Static prop values and Component Default Data remain available for purely presentational content. + +8. **Verify the rendered route.** The host app's catch-all route (default + recommended: `app/[[...slug]]/page.tsx` for Next.js, `` for React Router) mounts `` and handles every URL — including the freeform template's resolved `urlPattern`. No per-template route is needed; Studio's CDA query inside `sdk.fetchCompositionData` resolves the composition. + + Open the resolved URL in the browser to confirm the pinned content appears. If it 404s, the catch-all isn't wired — run [`setup-template-preview-routes`](../setup-template-preview-routes/SKILL.md) first. + +## Inputs needed from the user + +In order. Two are optional — skip the corresponding step if blank. + +1. `templateName` — template display name (required) +2. `urlPattern` — URL pattern; defaults to `/compositions/{{composition_uid}}` (required). Avoid encoding `{{locale}}` — carry locale via routing + the SDK `locale` query option. +3. `pinnedEntryContentTypeUid` — optional; skip step 5 if blank +4. `pinnedQueryContentTypeUid` — optional; skip step 6 if blank + +If the user is unsure which content types to pin, ask what data the page needs to show (single entry vs. list) and map accordingly. + +## Acceptance + +This skill succeeds only when ALL applicable items below are true. If any fails, do not claim success — surface the failure and stop. + +- [ ] **FREEFORM MODE** badge visible in the canvas top bar. +- [ ] **PREVIEW COMPOSITION:** label (not PREVIEW ENTRY) shown in the preview row. +- [ ] Edit URL modal shows the `{{composition_uid}}` chip and no `{{entry.*}}` chips; the saved pattern uses only allowed tokens. +- [ ] With nothing selected on canvas, right panel → Data tab lists all three sections: Additional Entry Data, Pinned Queries, External Data. +- [ ] If a pinned entry was requested, it appears under Additional Entry Data with its title + content type and is reachable from a component's Data Picker. +- [ ] If a pinned query was requested, it appears under Pinned Queries with its Name + Content Type and is bindable as a Repeater **Items** source. +- [ ] The app route at the resolved URL mounts `` and renders the freeform layout with pinned content. (For lists, the Repeater shows real iterations after toggling **Preview Mode** in Properties — the canvas previews one iteration by default.) + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| Right panel shows only Settings | Freeform feature is OFF at project level | Enable it before continuing | +| A component is selected when you wanted template-level pins | Data tab swaps to component-prop bindings | Click empty canvas to deselect | +| Trying to use `{{entry.title}}` or `{{content_type_uid}}` in the URL pattern | Freeform has no entry / no connected content type | Only `{{composition_uid}}` + context vars work | +| "Static Template" (create modal) vs "Freeform Mode" (canvas badge) | Same thing, different label | Treat as one | +| Pinned Entry vs Pinned Query mix-up | Single entry → Additional Entry Data; list → Pinned Queries (Repeater source). A pinned query re-runs every render; a pinned entry does not refresh as content changes. | Pick by cardinality | +| Pinned query prompt isn't live | Natural-language prompt is converted once at save time | Re-open the query to tune later | +| Repeater renders one row on the canvas | Design Mode preview shows one iteration | Select Repeater in Layers and toggle **Preview Mode** in Properties | +| Template doesn't render at the resolved URL | Catch-all missing, or not mounting ``, or pattern doesn't resolve inside `sdk.fetchCompositionData` | Verify all three. The catch-all covers every URL; per-template routes are NOT needed. | +| Dropping a Rows / Box wrapper before the first real component | Leaves visible "Drop Here" placeholder zones. Rows is an e2e-test scaffolding habit, NOT a Studio authoring pattern. | Drop the first component directly onto the canvas root slot. Wrap in a container ONLY for explicit layout. | +| Adding a Repeater for a non-list use case | Repeater iterates a multi-valued field; one hero + one promo card needs zero iteration | Use Repeater ONLY when the data source is genuinely a list (Pinned Query, multi-entry Pinned Reference) | +| Leaving empty "Drop Here" zones at the bottom | Saved compositions render those zones as visible placeholders | Before Save, switch to Layers and delete any orphan empty Box / Slot rows | + + + +## LLM execution caveat — drag-drop works, but only with the right sequence + +Studio's canvas is a React-DnD iframe listening on `mousedown`/`mousemove`/`mouseup` (NOT HTML5 native drag). Use `page.mouse.down/move/up` directly — `dragTo()` doesn't work. + +**Stable selectors:** + +- Palette tile: `[data-builder-component="true"][data-node-type=""]` +- Canvas iframe: `[data-testid="canvas-iframe"]` +- Drop slot: `[data-composable-studio-slot="true"]` (the `="true"` filter is required) +- Layers row: `[data-testid="layer-editable-title-container"]` +- Node IDs: `[data-composable-studio-id]` + +**The drop sequence:** + +```ts +const item = page.locator('[data-builder-component="true"][data-node-type="doc-hero"]'); +const frame = page.frameLocator('[data-testid="canvas-iframe"]'); +const slot = frame.locator('[data-composable-studio-slot="true"]').first(); + +await item.hover(); // 1. position cursor over palette tile +await page.mouse.down(); // 2. mousedown → posts PARENT_DRAG_START to iframe +const sb = await slot.boundingBox(); +await page.mouse.move(sb.x + sb.width / 2, // 3. move cursor in STEPS — required for mousemove events to fire + sb.y + sb.height / 2, + { steps: 10 }); +await slot.hover(); // 4. final settle on the slot (FrameLocator handles cross-frame) +await page.mouse.up(); // 5. mouseup → commits the drop +``` + +The `move({steps: 10})` is critical — without intermediate mousemove events the drop is silently swallowed. + +**Anti-phantom guardrail.** Verify a NEW `data-composable-studio-id` appeared after each drop: + +```ts +const idsBefore = await frame.locator('[data-composable-studio-id]') + .evaluateAll(els => els.map(e => e.getAttribute('data-composable-studio-id'))); +// ... drop sequence ... +await page.waitForTimeout(800); +const idsAfter = await frame.locator('[data-composable-studio-id]') + .evaluateAll(els => els.map(e => e.getAttribute('data-composable-studio-id'))); +const newIds = idsAfter.filter(id => !idsBefore.includes(id)); +if (newIds.length === 0) { + throw new Error('Drop did not commit; do not continue.'); +} +``` + +If `newIds.length === 0`: stop and surface the failure. + +**Sibling drops.** Once root is occupied, hover the **edge** of an existing node for a drop indicator, or wrap children in a `box`/`vstack`/`hstack`. + +**Also works programmatically:** Layers row + `Delete`; Save button; right-panel tab switches; opening Configuration / URL Pattern modals; reading iframe state via `frameLocator`; switching palette accordion sections. + +## See also + +- `install-studio` — install SDKs and wire `studioSdk.init` at the app shell. +- `setup-section-preview` — add the `` route for previewing Sections in Studio. +- `docs/33-freeform/freeform-templates.md`, `page-data-tab.md`, `pinned-entries.md`, `pinned-queries.md` — reference documentation. diff --git a/codex/build-repeating-section/SKILL.md b/codex/build-repeating-section/SKILL.md new file mode 100644 index 0000000..67845fe --- /dev/null +++ b/codex/build-repeating-section/SKILL.md @@ -0,0 +1,212 @@ +# build-repeating-section + + +## When to use + +Build a Section that renders a list of cards/tiles — parent Section with Repeater + Section Slot, plus child Section bound to the iterated item. + +Use when a Section renders N cards/tiles from a multi-valued field (Reference, Modular Block, Group multi, multi-Reference). Phrases — "card grid", "list of N items", "iterate related posts". Do NOT use for single-instance compositions (use `build-section`). For nested repeaters, see `author-composition-via-api` § *Recipe — Nested Repeaters* (hand-author via API; Studio's UI can't author that shape). + +# Build a repeating Section — parent + child pattern + +## Context + +The canonical "repeat the same card per item" pattern (product grids, blog lists, related items) — **two Sections working together**: + +- A **parent Section** owning the layout container, Repeater, and one Section Slot inside the iteration. +- A **child Section** bound to the iterated item's CT, dropped into the parent's Slot at template-authoring time. Bindings come along; no per-prop clicks. + +This is **Path B** from `build-section`. See `build-section` § *Path A vs Path B*. + +Composes: `build-section` (child + parent frame), `use-repeater`, `use-section-slot`, `understand-section-slots`. + +## When to skip the Repeater entirely — the array-prop alternative + +Before reaching for the parent+child+Repeater pattern, check whether a **single registered component with an array prop** would work. If the list rendering is straightforward (no per-iteration variant authoring, no template-author swap) and the items come from a single **Reference field**, this path is simpler AND avoids the Repeater + Condition Block schema-disambiguation dance entirely. + +The pattern: + +1. **Register a component** with an `array`-typed prop (e.g. `cards: { type: "array" }`). +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. + +```tsx +// Registered component +registerComponent({ + type: "doc-card-list", + component: CardList, + schema: { + cards: { type: "array" }, // <-- bound to a multi-Reference field on the section's linked schema + }, +}); + +function CardList({ cards }: { cards: Array<{ title: string; url: string }> }) { + return ( + + ); +} +``` + +**Why this works:** the SDK's data-binder handles `type: "array"` bindings by reading the bound array directly. When `resolvedReferences` includes the field, the array arrives populated with full entries — the component renders them with its own JSX. No iteration scope means no need for a Condition Block to disambiguate per-iteration item schemas. + +**When the Repeater pattern is still right:** + +- Author needs to **swap a different child Section per template instance** via the Section Slot (the whole reason the Repeater + Section Slot pattern exists). +- The list iterates a **Modular Block** (polymorphic — different block types need different rendering; the Repeater + Condition Block sibling chain is built for this). +- The Studio canvas should show **real iteration preview** of N rendered child Sections (Preview Mode). +- The same iteration shape is reused across multiple Sections. + +**When the array-prop pattern wins:** + +- The list is a **single Reference field** (or single multi-entry pinned query) with one rendering shape. +- The component is owned by engineering and won't change per template instance. +- You want to skip the Repeater UI overhead and the schema-disambiguation requirement. + +If unsure, default to the Repeater pattern documented below — it's the canonical Studio path and surfaces variability to authors. The array-prop alternative is a known-good shortcut for reference-list cases where author variability isn't needed. + +## Prerequisite — the canvas chain must be wired + +This is a canvas operation. If the Section canvas is blank, run `setup-section-preview` first. + +## Task + +### Step 1 — Build the child Section first + +If `childSectionName` doesn't exist yet, build it now via `build-section`: + +- Linked schema: `childLinkedSchema` +- Drop registered components for the card visual; bind their props via Data Picker against the child's linked schema +- Slot-candidate scan: expose Slots for any Global Field / Modular Block / Group / Reference fields (see `understand-section-slots`) +- Components stay **layout-agnostic** — see `register-component` § *Layout contract* + +Gate: open the child standalone; it must adapt at any canvas width. If it renders full-bleed or collapses, fix the child's layout before continuing. + +### Step 2 — Create the parent Section frame + +Open Studio → Sections tab → + 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. + +### Step 3 — Drop a layout container Box first + +**Don't drop the Repeater bare onto the Section root** — without a layout container the Slot gets a full-canvas-width cell (see `use-section-slot` § *Layout container*). Drop a **Box** onto the Section root, configure per `layoutShape`: + +| `layoutShape` | Box CSS | +|---|---| +| `grid` | `display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 24px;` | +| `flex-row` | `display: flex; flex-wrap: wrap; gap: 16px;` with `flex-basis: calc(33% - 16px)` on the child cell | +| `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. + +### Step 4 — Drop a Repeater inside the Box + +Drag **Repeater** from the palette's Smart Containers category into the Box. + +Bind the Repeater's **Bind items** property to `iterationSourceField` via the Data Picker. The picker shows the parent Section's linked-schema fields; pick the multi-valued field (the picker disables scalars for this slot). + +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) + +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). Two ways: + +- **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. + +For `group` / `array` / `pinned-query`, no Condition Block is needed. + +### Step 6 — Drop exactly ONE Section Slot inside the Repeater (or its Condition Block) + +Drag a **Section Slot** from the palette into the Repeater (or the Condition Block from Step 5). One Slot, 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. + +**Do not** drop the child Section directly into the Slot at this point. The Slot is filled at template-authoring time (Step 8). In the parent Section's own canvas, the Slot is an inert labelled placeholder. + +### Step 7 — Save the parent Section + verify on its own + +Save. In Layers, the tree should read: + +``` +Section "" +└── Box (layoutShape container) + └── Repeater (bound to ) + └── (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. + +### Step 8 — Drop the child Section into the Slot at usage time + +When the parent Section is dropped on a Template (or in another Section's Slot), each iteration shows the empty Slot. Drag `childSectionName` from the Sections palette into the Slot. + +What happens: +- Studio drops the child Section into the Slot +- 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. + +### Step 9 — Toggle Preview Mode to verify + +Select Repeater → right-panel **Configuration** → toggle **Preview Mode** on. See `use-repeater` step 7. The canvas should render N iterations with real per-item data. + +Troubleshooting: +- Wrong iteration count → re-check Step 4 Repeater binding. +- Same value across iterations → child's linked schema doesn't match iterated item's CT. +- Card full-bleed → parent Box (Step 3) doesn't define cell size. + +## Inputs needed from the user + +Collect in order; don't guess `iterationSourceField` or `iterationItemKind`. + +1. `parentSectionName` — the grid / list container name +2. `parentLinkedSchema` — the CT / Global Field the parent binds to +3. `iterationSourceField` — the multi-valued field on that schema +4. `iterationItemKind` — reference / modular-block / group / array / pinned-query +5. `childSectionName` — the card / tile Section (built in Step 1 if missing) +6. `childLinkedSchema` — what the child binds to (should match the iterated item's CT) +7. `layoutShape` — grid / flex-row / vertical-list / horizontal-scroll + +## Acceptance + +This skill succeeds only when ALL are true: + +- [ ] The child Section exists, is layout-agnostic, and renders correctly standalone at any width. +- [ ] The parent Section exists with the tree: Section → Box → Repeater → (Condition Block, if needed) → Section Slot. +- [ ] 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). +- [ ] 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). + +## Common pitfalls + +| Pitfall | Fix | +| --- | --- | +| 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). | +| 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. | +| Preview Mode on but Repeater still empty | Toggle isn't the issue — diagnose binding (Step 4), source field has items, Condition Block in place (Step 5), child schema matches. See `troubleshoot-data-binding`. | +| Monolithic Section (Path A) when card is reusable | Use Path B — see `build-section` § *Path A vs Path B*. | + +## See also + +- `build-section` — for both the parent and child Sections; covers Path A vs Path B +- `use-repeater` — for the Repeater step +- `use-section-slot` — for the Slot step +- `use-condition-block` — for Reference / Modular Block iteration +- `understand-section-slots` — what fills a Slot (a Section, not a raw component) +- `register-component` — the layout-agnostic contract for components inside the child Section +- `byoc-end-to-end` — the broader BYOC arc this skill fits into diff --git a/codex/build-section/SKILL.md b/codex/build-section/SKILL.md new file mode 100644 index 0000000..b6b8aaf --- /dev/null +++ b/codex/build-section/SKILL.md @@ -0,0 +1,278 @@ +# build-section + + +## When to use + +Author a Section composition by linking it to a structural schema (CT / Global Field / Group / Modular Block / Block / Reference) and dropping registered components. + +Use when a NEW reusable Section must exist for a Template to drop. Run AFTER components are registered; before Template work. Phrases — "build a hero section", "create card grid". Do NOT use to MODIFY an existing Section — use Studio UI. Do NOT use for one-off page composition — use `build-connected-template`. + +# Build a Section + +## Context + +A Section is a reusable composition that template authors drop onto pages. + +**⛔ Qualification gate — before authoring, confirm this Section qualifies.** A Section pays off when **the binding work amortizes** — i.e., when the same N bindings would otherwise be repeated by the author on every template drop. Qualifies if either: + +1. **The component has ≥2 separate prop bindings** — Hero with `title` + `cover.url`; Card with `image` + `title` + `summary` + `link`; ProductHighlight composed of multiple sub-components each with their own bindings. Without a Section, the author re-wires every prop on every drop. +2. **The component takes one prop bound to a structural shape** — Modular Block list, Reference array, Group, Global Field. The outer bind is 1 click, but the internal rendering walks N sub-fields per iteration; that sub-field work amortizes. + +A Section does **NOT** qualify for: + +- **A single atomic component bound to a single primitive (scalar) field** — `Heading` bound to `entry.title`; `Image` bound to `entry.cover.url`; `Text` bound to `entry.tagline`. One bind, one drop, no repetition cost. Studio's Connect-a-Schema picker also rejects scalar fields. Use these components inline. + +**Diagnostic question:** *"How many bindings will the author wire by hand on the next drop of this component?"* If ≥2 → make it a Section. If 1 → leave it as a registered component used inline. + +If the candidate fails the qualification — STOP. It's not a Section. See [`understand-sections`](../understand-sections/SKILL.md) § *What qualifies as a Section*. + +Its data contract is defined by a **linked schema** — and Studio only accepts **structural shapes** here, not single scalar fields. Allowed kinds: + +| Kind | What it is | Single or multiple | +|---|---|---| +| **Content Type** | The whole entry of one CT | Single — one entry at a time | +| **Global Field** | A reusable named schema (the most common section anchor) | Single OR multiple (a list of group instances) | +| **Group** | A nested object on a CT | Single OR multiple (a list of group instances) | +| **Modular Block** | A field that holds a list of blocks; each block can be a different shape | Always multi — pair with Repeater + Condition Block | +| **Block** | A *specific* block type inside a Modular Block field | Single — the block shape is fixed | +| **Reference** | Points to one or more entries of one or more CTs | Single OR multi-entry — multi pairs with Repeater + Condition Block (always required, single-CT included) | + +A section connecting to a *multiple* variant is meant to live **inside a Repeater** on the template — the Repeater iterates the list; each iteration places one instance of the section's shape. + +Studio's picker omits scalar fields (`text`, `number`, `boolean`, `file`, `link`, `date`, etc.). A section composes UI against a *shape*; for a single value override at template level, use **Expose Section Prop** on a component inside the section. + +Bindings on registered components come from the Data Picker. The picker shows the Section's linked-schema fields by default; inside a Repeater it switches root to the iteration item's fields. See `use-repeater` § *The scope rule, in user terms*. All References inside a Repeater — single-CT and multi-CT — require a Condition Block. + +## Path A vs Path B — monolith Section or Section-with-Slot + child Section + +When a Section iterates over a list of cards (a multi-reference field, a Modular Block list, a multi-Group, or a multi-entry pinned query), there's a recurring architectural choice: + +- **Path A — one monolithic Section.** The Section owns the Repeater AND the card markup inside it. Card props bind from the Repeater Data root (the iterated item). +- **Path B — Section-with-Slot + separate child Section.** The parent Section owns the Repeater and exposes a Section Slot inside the iteration. A separate child Section (bound to the iterated item's CT) gets dropped into the Slot at template-authoring time. The child Section is pre-bound to its own linked schema; no per-prop binding needed. + +**Deciding question:** *"Will this card visual ever appear outside this list?"* (Featured product hero, related-items strip, recommended carousel, search result row, etc.) + +- **Yes / probably yes / unsure → Path B.** Sections exist so visuals can be reused. Splitting the card into its own Section costs one extra Section and unlocks reuse everywhere else. **Default to Path B.** +- **Genuinely no, single-use → Path A** is acceptable. Easier to collapse Path B into Path A later than to extract a Section out of a monolith — when in doubt, Path B. + +In Path B, the parent's Repeater contains a Section Slot (see `use-section-slot`), filled at template time by the child Section — never a raw component (see `understand-section-slots`). Auto-binding wires it because the child Section's linked schema matches the iterated item. + +## Layout lives in the Section + +A Section that exposes a Slot must wrap it in a sized layout container. See `use-section-slot` § *Layout container* and `register-component` § *Layout contract*. + +Reference: `docs/32-sections/overview.md`, `docs/32-sections/link-content-types-with-linked-schema.md`, `docs/20-bring-your-own-components/register-components.md`, `docs/34-smart-containers/create-repeatable-content-with-repeaters.md`. + +## How a Section gets its data — the `selectedField` model + +A Section has **no data of its own**. When it's dropped on a template, the SDK hands it a scoped slice of the page entry via `getScopedData(pageEntry, selectedField)`: + +- **`selectedField` set** to a field uid → the section's `template` becomes that field's value (references and groups resolved along the path). +- **`selectedField` unset** → the section's `template` becomes the **whole page entry**. + +The section's own canvas therefore always shows **one empty placeholder** (no page context exists standalone). That's not a bug — the section only fills out at template-render time. Verify via SSR cold-load: `await sdk.fetchCompositionData({ url })` on the parent template's URL and dump `spec.data.section_scoped_data[]`. + +### Decision rule — set `selectedField` or leave it unset + +| 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 | +| **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`. + +Full mechanism, wire shapes, and the reference-iteration `data_sources.resolvedReferences` handling: `author-composition-via-api` § *Authoring a Section composition — scoping rules* and § *Decision rule — single-field repeater section vs whole-entry section*. + +## Basic field components vs custom registered components — the simple-vs-styled tradeoff + +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. | +|---|---|---| +| 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. | +| 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. + +**Recommended pattern — hybrid.** Use both inside one Section: + +- **Custom registered components** for the styled / interactive shells — the hero with its background, the carousel with its animations. +- **Basic field components** for editorial copy that doesn't need a custom shell — headline, intro paragraph, footnote — or for repeated content blocks where authors should be able to swap copy quickly via the right panel without engineering ever touching the codebase. +- **Match the Basic-component styling to your brand** by setting node `responsiveStyles` via the Design panel (typography, spacing, color) — or write CSS that targets the rendered semantic DOM. For RTE, register a custom renderer (`register-json-rte`). + +Decision rule: + +- Is THIS region a single string / scalar value that maps 1:1 to a CT field (or is composition-local copy)? → Basic field component. +- Is THIS region a multi-prop / styled / interactive piece? → Custom registered component. +- Both, in the same Section, is normal and good. + +## Prerequisite — the canvas chain must be wired + +Section authoring requires the full canvas chain: a route mounting ``, the project's **Canvas URL** pointing at it, and — most often missing — a **non-empty per-locale Base URL on the environment the project targets**. Missing any link = blank or Playground canvas with no explanation. If you can't confirm all three, run [`setup-section-preview`](../setup-section-preview/SKILL.md) first. + +## Task + +1. **Open Studio → Sections tab → + New Section.** Confirm you are in section authoring mode (the palette shows the **Registered Components** category and **Smart Containers** category — both are section-mode signals). + +2. **Name the section** using the supplied `sectionName`. The display name is what template authors see in the Sections palette. + +3. **Connect A Schema.** The Schema panel offers structural shapes only — no scalar fields. Pick the kind matching `linkedSchemaKind`: + - `content-type` → pick the connected CT + - `global-field` → pick the Global Field UID + - `group` → pick the group field on the parent CT + - `modular-block` → pick the Modular Block field; the section sees the union of allowed block types + - `block` → pick a specific block type inside a Modular Block; the section sees just that block's fields + - `reference` → pick the Reference field; the section sees the union of `reference_to` CTs + + If the user asks to "link to the title field" or similar, redirect: that's not how sections work. Either pick the parent group / object / CT, or use **Expose Section Prop** at the component level later. If `linkedSchemaUid` is blank, warn explicitly: the section is static-only, no auto-binding, every value typed by hand at template-drop time. + + When the picked schema is a *multiple* variant (`isMultiple=y`), note it in the section's intent — this section is meant to be dropped *inside a Repeater* on a template. The Repeater iterates the list; each iteration places the section once. + +4. **Scan the linked CT for Slot candidates BEFORE binding fields directly.** Inspect the CT for any **Global Field**, **Modular Block**, **Group**, or **Reference** fields. For each one, decide: + - **Expose it as a Section Slot** (the preferred default) when the nested data is composable, shared across pages, or likely to have variants. Carve a Slot at that location via `use-section-slot` instead of binding the nested shape inline. Doing so keeps the nested shape replaceable with any compatible Section per template instance. + - **Bind it inline** only when the shape is genuinely one-off, small, and unlikely to be reused. This is the rarer case. + + Default for Global Field / Modular Block / Group / Reference fields: **expose a Slot**. Inlining throws away the reusability of the nested structure. If unsure, expose a Slot — it can always be filled with a single fixed child Section. + + See `understand-section-slots` § *When to expose one — the Global Field / Modular Block / Group / Reference heuristic* for the full rationale (Global Fields are the strongest Slot candidate of the four). + +5. **Drop registered components.** For each entry in `initialComponents`: + a. Open the palette and switch to the **Registered Components** category. Do not drop Studio default components — they bypass the project's brand system. + b. Drag the component onto the section canvas. Use the canvas drop indicators to confirm placement inside the intended container. + c. Select the dropped node and bind each prop via the Data Picker. The picker shows the Section's linked-schema fields by default. If the dropped node sits inside a Repeater, the picker switches root to the **iteration item's** fields automatically (labelled "Repeater Data") — bind from whichever root the picker is showing. Add a Condition Block around any Reference or Modular Block iteration. + +6. **Optional smart containers.** + - For a section whose linked schema IS the *multiple* variant, you usually drop a Repeater bound to `template.items[]` (or whatever the array path is) and place sub-components inside it. + - Carve a **Section Slot** (see `use-section-slot`) if you want template authors to drop arbitrary components into a named region. + - **Expose Section Props** (see `expose-section-props`) for value-level overrides — toggling a flag or swapping a label per template instance. + +7. **Save.** Studio surfaces the **Expose Props** modal on Save — toggle which component props template authors should be able to override per page. If you skip this step the section is locked: template authors can drop it but cannot change any value. + +## Inputs needed from the user + +In this order. Stop and ask if any is missing — DO NOT guess a `linkedSchemaUid` or invent component types. + +1. `sectionName` — display name (reject empty or generic "Section 1"; ask again). +2. `linkedSchemaKind` — one of `content-type / global-field / group / modular-block / block / reference`. If the user says "field name X", redirect: ask for the enclosing structural shape. +3. `linkedSchemaUid` — UID of the structural shape; allow skip with an explicit static-only warning. +4. `isMultiple` — y/n; affects whether the section is intended to live inside a Repeater on the template. +5. `initialComponents` — comma-separated list of registered component types; reject any type not currently registered in the project. + +## Acceptance + +This skill succeeds only when ALL of the following are true. If any fails, do not claim success — surface the failure and stop. + +- [ ] The new Section appears in the Studio Sections tab with the supplied `sectionName`. +- [ ] If `linkedSchemaUid` was supplied, the section's Schema panel shows that UID under the correct structural kind (and **not** under any individual field). +- [ ] If the user asked to link to a scalar field, the request was redirected to the enclosing shape (or to Expose Section Prop) — single-field linking was NOT attempted. +- [ ] When `isMultiple=y`, the section's intent is documented for template authors: "drop inside a Repeater". +- [ ] Every component listed in `initialComponents` exists on the section canvas and was dropped from the **Registered Components** palette category (not the Studio defaults). +- [ ] Each bound prop on those components resolves under the right Data Picker root — the Section's linked-schema root for props outside any Repeater, and the **Repeater Data** root (iteration item) for props inside a Repeater. A Condition Block wraps any Reference or Modular Block iteration. +- [ ] On Save, the **Expose Props** modal was acknowledged — either props were exposed or the locked state was a deliberate choice. +- [ ] Dropping the saved section onto a template that has a field of the linked schema's shape auto-binds with no manual picker steps. +- [ ] **Migration builds only** — if this Section is replacing an existing production Section on a live route, run `verify-visual-parity` against the production URL at all target viewports before declaring success. Structural checkpoints (component present, prop bound) are necessary but not sufficient; pixel drift ships silently otherwise. Skip this line for greenfield Sections that have no production counterpart to compare. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| Trying to link the section to a scalar field (`title`, `description`, `image`) | Studio's picker doesn't offer scalar fields — a section composes UI against a *shape*, not a value | Pick the parent Group / Global Field / CT / Block; expose individual props later via Expose Section Prop | +| Linking to a multi-variant schema without dropping a Repeater | Section composes against the whole list as one shape; renders only the first item or fails to bind | When `isMultiple=y`, drop a Repeater bound to the array path and place components inside it | +| Picking Modular Block instead of a specific Block type | Bindings are typed against the union of block shapes — most fields don't resolve | If the section is for one block type, pick `block` and select that specific block; if the section handles many, use Repeater + Condition Block per block type | +| Skipping Connect A Schema | Section is static-only; won't auto-bind on templates; authors wire every value by hand | Pick a structural schema; only skip when you know the section is purely presentational | +| Dropping Studio default components instead of Registered Components | No brand consistency; section ignores the project's component library | Switch palette category to **Registered Components** before dragging | +| Skipping the Expose Props step on Save | Template authors get a locked section — no value overrides possible | Toggle the props that should be overridable in the Expose Props modal | +| Assuming the Section is broken because the canvas shows defaults, not CMS values | Design Mode renders registration defaults; Preview Mode renders real bindings. See `use-repeater` for the two-mode model. | Toggle Preview Mode in Properties → Configuration. | +| Binding inside a Repeater without a Condition Block on references / modular blocks | Iteration items are polymorphic; bindings silently fail | Wrap with Condition Block (single-CT references included) | +| Picking from the Section's linked-schema root for a prop that sits inside a Repeater | Wrong scope — every iteration shows the same value (the parent's field), not the per-item value | Inside a Repeater, the Data Picker switches to the iteration item's fields automatically — bind from that root | +| Naming the section "Section 1" / "New Section" | Authors cannot tell sections apart in the palette | Use an intent-revealing name like `Hero Strip`, `Card Grid`, `Testimonial Card` | +| Dropping a **Rows / Box wrapper** before the first real component | Leaves a visible "Drop Here" placeholder zone in the canvas (and in every screenshot) that authors must clean up later. Rows is an e2e-test scaffolding habit, NOT a Studio authoring pattern. | Drop the first component directly onto the canvas root slot. Wrap in a container ONLY when you need explicit layout (e.g. an `hstack` to put two cards side-by-side). | +| Adding a **Repeater for a single, non-list use case** | Repeater iterates a multi-valued field; a static page with one Hero + one Product Card needs zero iteration. Adding one introduces a phantom iteration scope and shifts the Data Picker so the parent Section's fields aren't reachable from inside. | Use Repeater ONLY when the linked field is genuinely a list (Modular Block list, multi-Group, multi-Reference, multi-entry pinned query). For a single hero or single card: drop the component directly. | +| Path A when the card visual may be reused elsewhere | Locks the card into one parent; reuse contexts drift out of sync | Default to Path B — see § *Path A vs Path B*. | +| Slot inside Repeater without a sized layout container | Child stretches full canvas width | Wrap in grid/flex/`max-width` Box. See `use-section-slot` § *Layout container*. | +| Leaving empty "Drop Here" zones at the bottom of the canvas | Saved compositions render those zones as visible placeholders in screenshots and to authors browsing the section gallery. | Before Save, switch to Layers and delete any orphan empty Box / Slot rows. Acceptance: the Layers tree contains ONLY the components you intended; no orphan structural wrappers remain. | +| **Assigning a CSS class to a Basic `box` and expecting `display: flex/grid` to apply** — the box renders but the children stack vertically (no flex) or single-column (no grid). | The Basic `Box` component is just `

` — Studio renders box layout from the node's **`styles.default.responsiveStyles.default`** (set via the Design panel on the node) and surfaces it through the renderer's style pipeline. An external CSS class on the app side has no node-level styles to attach to and only applies whatever rules its stylesheet defines — it does NOT make Studio's renderer emit `display: flex`. | Author Basic-box layout via node `responsiveStyles` in the Design panel: select the Box → Design tab → set `display`, `gap`, `alignItems`, `flexDirection`, etc. The renderer writes these into the DOM. Reach for a CSS class only for tokens/colors/typography already wired through your design system — never for the load-bearing layout shape. (Custom registered components are different — they can apply layout via their own React/CSS.) | + + + +## LLM execution caveat — drag-drop works, but only with the right sequence + +Studio's canvas is a React-DnD iframe. Palette tiles listen on `mousedown` / `mousemove` / `mouseup` (NOT HTML5 native drag), and the drop COMMITS only when mousemove fires intermediate events between mousedown and mouseup. The high-level `dragTo()` helper fires HTML5 `dragstart`/`drop` which Studio does not honor — you must use `page.mouse.down()` / `page.mouse.move({steps})` / `page.mouse.up()` directly. + +**Stable selectors (verified by execution):** + +- Palette tile: `[data-builder-component="true"][data-node-type=""]` where `` is e.g. `doc-hero`, `doc-card`, `repeater`, `header`, `box`. (Section tiles use the section's composition UID as the type.) +- Canvas iframe: `[data-testid="canvas-iframe"]` +- Drop slot inside the iframe: `[data-composable-studio-slot="true"]` (the `="true"` filter is required; without it you can match elements that have the attribute but aren't active drop targets) +- Layers row title (to select a node for deletion or inspection): `[data-testid="layer-editable-title-container"]` +- Node IDs (to verify a drop committed): `[data-composable-studio-id]` inside the FrameLocator + +**The drop sequence — proven working pattern:** + +```ts +const item = page.locator('[data-builder-component="true"][data-node-type="doc-hero"]'); +const frame = page.frameLocator('[data-testid="canvas-iframe"]'); +const slot = frame.locator('[data-composable-studio-slot="true"]').first(); + +await item.hover(); // 1. position cursor over palette tile +await page.mouse.down(); // 2. mousedown → posts PARENT_DRAG_START to iframe +const sb = await slot.boundingBox(); +await page.mouse.move(sb.x + sb.width / 2, // 3. move cursor in STEPS — required for mousemove events to fire + sb.y + sb.height / 2, + { steps: 10 }); +await slot.hover(); // 4. final settle on the slot (FrameLocator handles cross-frame) +await page.mouse.up(); // 5. mouseup → commits the drop +``` + +The `page.mouse.move({steps: 10})` between mousedown and mouseup is the critical detail. Without intermediate mousemove events, the iframe's drag-tracking code never registers the path and the drop is silently swallowed. + +**Anti-phantom guardrail.** Always verify a NEW `data-composable-studio-id` appeared inside the FrameLocator after each drop: + +```ts +const idsBefore = await frame.locator('[data-composable-studio-id]') + .evaluateAll(els => els.map(e => e.getAttribute('data-composable-studio-id'))); +// ... drop sequence ... +await page.waitForTimeout(800); +const idsAfter = await frame.locator('[data-composable-studio-id]') + .evaluateAll(els => els.map(e => e.getAttribute('data-composable-studio-id'))); +const newIds = idsAfter.filter(id => !idsBefore.includes(id)); +if (newIds.length === 0) { + throw new Error('Drop did not commit; do not continue.'); +} +``` + +If `newIds.length === 0`: stop and surface the failure — do not fabricate completion. + +**Sibling drops after the root slot is consumed.** Once a component is dropped at the canvas root, `[data-composable-studio-slot="true"]` may return zero matches because the root slot is now occupied. To add siblings, hover the **edge** of an existing node — Studio reveals a drop indicator there. Alternatively wrap children in a container (`box`, `vstack`, `hstack`) and drop subsequent siblings into the container's slot. + +**Execution-path matrix:** + +| Path | Drag-drop status | +|---|---| +| Human in their own Studio browser | ✅ Native — this is how authors use Studio every day | +| Playwright with direct `page.mouse.down/move/up` access | ✅ Use the proven sequence above | +| Playwright `dragTo()` only | ❌ Fires HTML5 drag events Studio does not honor | +| Synthetic `DragEvent` dispatched from page-context JS | ❌ Same reason | + +**What ALSO works programmatically (verified):** + +- Click a Layers row + press `Delete` → removes the node and persists +- Click the Save button → persists the composition; the button greys out post-save +- Switch right-panel tabs (Settings / Design / Data) via direct DOM clicks +- Open Configuration / URL Pattern / Schema Picker modals via their action buttons +- Read iframe canvas state via `frameLocator` (read-only operations) +- Switch palette accordion sections (Basic / Media / Container / Smart Containers / Registered Components / HTML Elements) via direct DOM clicks + +## See also + +- `docs/32-sections/link-content-types-with-linked-schema.md` — how schema connection drives auto-binding +- `docs/20-bring-your-own-components/register-components.md` — getting components into the Registered Components palette +- `docs/34-smart-containers/create-repeatable-content-with-repeaters.md` — Repeater + multi-variant sections +- `docs/34-smart-containers/condition-blocks.md` — required wrapping for Reference / Modular Block iteration +- `use-section-slot` — carve a drop region into the section +- `expose-section-props` — value-level overrides on Save +- `build-connected-template` — drop the saved section onto a template with a matching field +- `build-repeating-section` — the dedicated guide for the "parent Section with Repeater + Slot, child Section fills the Slot per iteration" pattern (Path B from this skill, walked end to end) diff --git a/codex/byoc-end-to-end/SKILL.md b/codex/byoc-end-to-end/SKILL.md new file mode 100644 index 0000000..53649a3 --- /dev/null +++ b/codex/byoc-end-to-end/SKILL.md @@ -0,0 +1,146 @@ +# byoc-end-to-end + + +## When to use + +End-to-end orchestrator for the BYOC arc — chains register-component (lazy) → import-design-tokens → build-section → use-section-slot → build-connected-template → configure-csr-vs-ssr → setup-template-preview-routes → verify-setup. + +Use when an engineer wants to bring their own component library into a Studio-backed app end-to-end. Walks register → section → template → publish. Phrases — "BYOC", "use my components in Studio", "Studio with my design system". Do NOT use for one-off component registration (`register-component`) or once components already exist in Studio. + +# BYOC end-to-end — from registered components to a rendered Template + +BYOC ("Bring Your Own Components") chains the ~8 sub-skills that take registered React components → rendered Template. This orchestrator only names + orders them; it doesn't replace any sub-skill. + +## Prerequisite — concepts first + +If the user can't crisply define Section, linked schema, Template, and `` vs `` in one sentence each → run `start-here-zero-knowledge` first. + +## Task + +Print the plan below, customised for the user's `projectShape` and `renderMode`. Don't execute the sub-skills automatically — name them, and let the user invoke each one in turn. + +### Phase 0a — Architecture planning (run BEFORE Preflight) + +- **`plan-studio-architecture`** — requirements → printed architecture plan (Sections, Connected pages, content model, build order). Skip only if the user already has a written plan. + +Gate: printed plan with classified pages, content model, ordered Section + Template inventory, build order, sanity flags acknowledged. + +### Phase 0 — Preflight (run once) + +- **`analyze-project-fit`** — confirm React 18, single React instance, no peer override forcing 19. Catches the most common install foot-guns. +- **`install-studio`** — install SDK packages, pin React 18, wire `ContentstackLivePreview.init`, set env Base URL pre-flight. Output: SDK installed + env Base URL for target locale is non-empty. +- **`enable-visual-experience`** — confirm stack-level Live Preview is on (commonly missed; silently blocks the canvas). +- **`setup-section-preview`** — create the `/canvas` route mounting ``. Set the Canvas URL in Studio Project Settings. + +Gate: open Studio, open any composition (even an empty one). Canvas iframe loads cleanly (no blank frame, no Component Loading Error). If not → `troubleshoot-canvas` before continuing. + +### 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-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) → "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). + +### Phase 2 — Build the first Section + +- **`build-section`** — pick a linked schema (Content Type / Global Field / Group / Modular Block / Block / Reference). The picker only offers structural shapes; no scalar fields. **Default to Global Field as the section anchor** unless a different shape fits better. +- **Mid-step: scan for Slot candidates.** Inside `build-section` step 4, the user is told to inspect the linked CT for **Global Field / Modular Block / Group / Reference** fields. For each one, decide: + - Expose as a Slot (the preferred default) → run `use-section-slot` for that field. + - Bind inline (rare) → only when the shape is genuinely one-off. + - Global Field is the strongest Slot candidate of all four — build a separate Section against the Global Field's schema and expose it as a Slot in the parent. +- **`expose-section-props`** — for any prop the user wants overridable per Section-drop (e.g. background colour, layout variant). Do this WHILE building the Section, not later. +- **`wire-component-default-data`** (optional) — for components whose defaults aren't representable as static `defaultValue:` literals. + +Gate: the Section appears in the Studio Sections tab. Open it on the canvas → bindings render real data from a sample entry. If not → `troubleshoot-data-binding`. + +### Phase 3 — Repeat Phase 2 for every Section the site needs + +- Most pages need 3–6 Sections (Header, Hero, Feature Grid, Testimonial, CTA, Footer, etc.). +- Build them ONE AT A TIME and verify each before moving on. +- Cross-references: if a Section iterates a list (Modular Block, multi-Reference, multi-Group), use **`build-repeating-section`** — the dedicated guide for the parent-with-Slot + child-Section pattern, which composes `use-repeater` + `use-section-slot` + `use-condition-block` into one flow. Don't drop raw cards into a repeating Slot; build the card as its own Section and let the parent's Slot accept it. +- Discoverability: run `discover-sections` periodically to see what's already been built — avoids accidentally rebuilding a Section that already exists. + +### Phase 4 — Choose Template flavor + assemble + +- **`build-connected-template`** — assemble the Sections from Phase 3 into the Template. Templates compose Sections; they don't model fields directly. If the user hasn't built any Sections yet, back to Phase 2. + +Gate: the Template renders on the canvas with the expected Section stack. Switching the preview entry updates the rendered content. If not → `troubleshoot-composition-resolution`. + +### Phase 5 — Visitor render path (CSR vs SSR vs RSC) + +- **`configure-csr-vs-ssr`** — the canonical decision skill. For `renderMode = unsure`, this skill picks based on the host framework (Next App Router → RSC default; Vite + React Router → CSR; Remix → SSR). Output: a clear recommendation and the per-route shape to use. +- **`setup-template-preview-routes`** — create the visitor route(s) mounting ``. Either a catch-all `[[...slug]]` (recommended for most projects) or per-template routes (more control, more boilerplate). The route(s) here serve BOTH real visitors AND Studio's template-preview iframe — same code path. + +Gate: open `` + `