# Content modeling for the CMS editor

Read this before creating or changing any data type. The measure of a good model is not the database — it is **how the record looks in the admin when a non-technical editor opens it**. They should see one coherent list, drag items, and pick related records by name: no slugs typed by hand, no choice of irrelevant types, no template edits.

## Decision tree

Ask, in order:

1. **Is this a page / composition of DIFFERENT types in a visible order?** → one polymorphic `blocks` array on the page record (property `entity`, `is_array: true`, **no `entity_ref`**). Example: `site-page.blocks` = hero, catalog, gallery, FAQ, CTA… Order = array order. Template: `{% for b in page.blocks %}{% case b.__typename %}`.
2. **Is it a repeated list of ONE type, in a visible order?** → typed `entity[]` on the record that OWNS the content (property `entity`, `entity_ref: "<child-slug>"`, `is_array: true`). Examples: `sec-faq.questions → faq-item[]`, `sec-catalog.products → product[]`, `product.colors → color[]`, `site-page.nav → nav-item[]`. Order = array order. No `position`. No global query.
3. **Is it a single value/property of one record?** → scalar, localized scalar, or a single entity reference (`entity` + `entity_ref`). A reference is never a free-text slug: `product.default_color → color`, not `"yellow"`.
4. **Do several sections need the same list?** → the list has ONE owner (the entity that semantically contains it); other sections resolve it in the template (see “Borrow, don't copy”). Never keep a global `products` / `colors` / `navItems` query as a workaround.
5. **Is it true site chrome?** → the singleton record (page/settings), and still model lists as `entity[]` (`page.nav → nav-item[]`), not as a global ordered query.

## Why a union is not the default

`blocks` resolves to the platform's **polymorphic union of ALL entity types**; the admin block picker lists every non-builtin type except `page`/`media` (so `product`, `member`, `faq-item` are offered too). The template has to filter `__typename`. That is correct when heterogeneity is the point (page sections). It is overkill — and actively worse for the editor — for a list of one type:

| Want | Model |
|---|---|
| FAQ list | `sec-faq.questions → faq-item[]` — picker locked to FaqItem; `questions { question answer }` |
| page sections | `site-page.blocks` (union) — picker lists section types; template `case __typename` |
| gallery photos + chat quotes rendered as two strips | two typed lists `photos → gallery-item[]`, `quotes → chat-quote[]` — NOT one mixed union (a drag between a photo and a chat changes nothing visible) |

If a mixed list really is one visual stream (a feed where items interleave), a union is right; if the template splits it into separate strips, use separate typed lists.

## Schema snippets (flowctl)

Typed list:

```
./flowctl entity add-field sec-faq --name questions --type entity \
  --entity-ref faq-item --is-array --label "Вопросы и ответы" --yes
```

Single reference:

```
./flowctl entity add-field product --name default_color --type entity \
  --entity-ref color --label "Цвет по умолчанию" --yes
```

Page union:

```
./flowctl entity add-field site-page --name blocks --type entity \
  --is-array --label "Блоки страницы" --yes
```

Data formats in `entity update` / mutations:

- typed list → `{"questions":["id1","id2"]}` (input `[ID]`);
- single ref → `{"default_color":"color-id"}` (input `ID`);
- polymorphic blocks → `{"blocks":"[{\"id\":\"…\",\"type\":\"sec-hero\"}, …]"}` (input `String`, a JSON **string**).

Order is the array order. Do not add `position`.

## Query and template

Typed list — plain selection, no fragments, no `__typename`:

```
... on SecFaq {
  title lead
  questions { question answer }
}
```

```
{% for q in fqs.questions %}
<details><summary>{{ q.question }}</summary><p>{{ q.answer }}</p></details>
{% endfor %}
```

Page union:

```
sitePages(where:{slug:{eq:"home"}}, limit:1, locale:$lang) {
  slug
  nav { label anchor }
  blocks {
    __typename
    ... on SecHero { … }
    ... on SecFaq { title questions { question answer } }
  }
}
```

```
{% for b in page.blocks %}{% case b.__typename %}
  {% when "SecHero" %}…{% when "SecFaq" %}…{% endcase %}{% endfor %}
```

## Borrow, don't copy

If a section needs data owned by another section, resolve the owner **before** the render loop, so page order does not matter:

```
{% assign catalog_sec = page.blocks | where: "__typename", "SecCatalog" | first %}
{% assign handles_sec = page.blocks | where: "__typename", "SecHandles" | first %}
{% assign products = catalog_sec.products %}
{% assign handles = handles_sec.handles %}
```

The builder (a different block) then uses `products` / `handles`; it does not own a second copy of the list. Copying the same references into two arrays creates drift: adding a colour to one copy silently leaves the other stale.

## Naming and labels

- Field captions are `config.label`. The admin renders the label everywhere an editor sees a field (record form, list columns, inline editors, validation) and falls back to the raw name when no label is set. Set a label on EVERY editor-visible field: `flowctl entity add-field … --label "Вопросы и ответы"`, a `label` key on the property inside `create-type --from` JSON, or the admin's type editor (a human admin can add/change labels there too).
- Keep the property NAME a clean identifier (`questions`, `rows`, `facts`, `stats`, `postcards`, `photos`, `quotes`, `products`, `colors`, `handles`, `nav` — not `items`, `list`, `data`, `block_1`): it is the API surface, the GraphQL field, and the fallback caption.
- Field ORDER is editor-visible. The record form renders properties in declaration order, and an entity[] list's row titles come from the FIRST non-empty string/text field in that order — put the human title field FIRST (`question` before `answer` on faq-item; the drag-order in the type editor is the same array). Author the WHOLE type in editor order with one `create-type --from` call; reserve `add-field` for schema migrations — its upsert REPLACES the property and can reorder existing fields (a relabeled field jumps position, silently flipping form order and row titles).
- The admin LIST title (records list, pickers) is the type's display field: by default the type's unique `slug` property, falling back to id. When the natural title is another field (e.g. a localized `title`), set it explicitly — `display_field` in the `create-type --from` JSON or `flowctl entity set-display-field <slug> <field>` after the fact. A type with no slug and no display field shows truncated ids in lists, which read as noise.
- Avoid the same generic name on multiple union members with different types (`image`: `string` vs `entity → media`): the union aliases the response and a template reading the bare field needs fallbacks. Specific names avoid it.
- Do not keep a boolean that duplicates list membership: to hide an item from a strip, remove it from that strip's list (`in_row` was removed for exactly this reason).

## Content, chrome, locales

- Every user-visible sentence, price and image comes from the CMS. Templates hold markup and micro-chrome (≤3 words).
- SSR fallbacks must be derived from CMS data, not typed into the template: the builder's initial summary/price come from the default product; a hardcoded `7 900 ₽` goes stale the moment the editor changes the price.
- Interface chrome that stays in the template must be localized server-side when routes are per-language (a small dictionary keyed by the route's `page_lang`), otherwise `/en` and `/es` show Russian buttons and aria-labels.
- Localized fields for all human-visible copy. Grammatical variants (e.g. colour `name` / feminine `fem` / accusative `acc`) are NOT duplication when different sentences need them.

## Migration and cleanup order

1. Add the new field(s); poll `./flowctl entity schema <type>` until the shape is live.
2. Fill the arrays with ordered ids.
3. Save/publish the query and push the template **before** removing the old field. Removing first leaves the live query selecting a missing field → empty pages.
4. Remove the old field(s) and the now-unused lookups: `slug` on section singletons, `position` on ordered lists, global list queries, dead booleans.
5. Re-render all locales and verify counts/order; `site publish` after content edits (content API writes do not bump the render cache).

Changing a field type in place (`string` slug → `entity` ref): pre-stage the query/template drafts, change the type, fill the ids, and publish immediately — the old live query is invalid while the type is new, so the window must be seconds, not minutes.

## Localized write trap

After `add-field --localized` the schema rebuild is asynchronous. Writing `{ru,en,es}` before it finishes stores a Go dump string `map[en:… ru:… es:…]`, which renders literally. Poll the schema until the field shows `(localized)`, then write. Detect: read the field **without** `locale:` and look for `^map\[`; fix by parsing the dump and rewriting the map after the rebuild.

## Verification checklist (before saying “done”)

- Open the page record in the admin (or reason from the schema): can the editor drag sections, expand a section, add/edit/remove its list items, and pick related records by name?
- Every field carries a `label` (or a name clean enough to show raw), and the human title field is FIRST in the property order — the record form, list rows and inline editors read naturally.
- Every list has one owner and one source of truth; no global query duplicates it.
- Every visible string/price/image is editable; no sentence hardcoded in Liquid.
- All locales render: right language, same counts, no `map[`, no empty `?text=` links, no Liquid leftovers.
- Swap two items in a list (and two page blocks) and confirm the rendered order changes; restore.
- `position` is gone from every array-ordered type; the only ordering is array order.

## Anti-patterns

| Anti-pattern | What the editor suffers |
|---|---|
| Global `products` / `colors` / `handles` / `navItems` queries | The list lives nowhere in the record they edit; no drag-reorder in context; two sources of truth |
| Polymorphic `blocks` for a single-type list | Picker offers every type; a wrong type renders nothing; `__typename` filters in the template |
| `position` + `orderBy` beside an array | Two ordering systems; drag does not stick or contradicts the field |
| Free-text slug reference (`default_color: "yellow"`) | A typo silently breaks the default; the editor must know slugs |
| The same refs copied into two arrays | Drift: one copy gets a new colour, the other does not |
| `in_row` / `is_visible` beside an explicit list | Two ways to hide; the editor cannot tell which wins |
| `slug` on a section singleton referenced as a block | Dead field; the block is referenced by id |
| Hardcoded price/summary in the template | The editor cannot change it; it goes stale |
| Russian chrome on `/en` / `/es` | Broken locale for buttons, labels, aria |
| Localized map written before the schema rebuild | `map[en:…]` visible in the content |

## Worked example: «Моя птичка»

- `site-page home`: `blocks` = 10 section types in page order; `nav` = 5 `nav-item[]`.
- `sec-faq.questions → faq-item[]`, `sec-about.stats → stat[]`, `sec-journey.postcards → postcard[]`, `sec-delivery.rows → delivery-row[]`, `sec-hero.facts → fact[]`, `sec-gallery.photos → gallery-item[]` + `quotes → chat-quote[]`, `sec-handles.handles → handle[]` (showcase).
- `sec-catalog.products → product[]`; each product owns `colors → color[]`, `handles → handle[]`, `default_color → color`.
- No global `products` / `colors` / `handles` / `navItems`; no `position` anywhere. The builder borrows `catalog_sec.products` / `handles_sec.handles` and re-renders its chips per selected product.
