# Widgets: platform blocks and their settings

> **Резюме по-русски.** Платформенные виджеты — быстрый путь: живые данные и
> рабочая пагинация ценой вида, ограниченного пропами. Правда о пропах живёт
> только в `GET /docs/widgets`: он генерится из настоящего разбора виджетов и
> разойтись с платформой не может. Главная ошибка агентов — §5: пропы вложены
> (`props.listing.…`), запись в корень `props` принимается и не делает ничего.

**Status:** current · **Verified:** 2026-08-19, live `GET /docs/widgets` + widget sources
**Owner:** agent-api · **Serves:** `GET /docs/widgets-area` · **Skill:** `vizen-widgets`
**Model note:** composition moved to groups on 2026-08-21 (`chrome.md` §3) —
the widgets themselves and every prop below are unchanged by it.
**Machine reference:** `/docs/widgets` — the generated props schema; that name was taken, hence `-area`

## 1. What you can do here

Place a platform block on a page and configure it. The block reads live shop
data — prices, stock and the category tree stay current by themselves. What you
give up is design freedom: the look is exactly as wide as the props the widget
declares, and no wider.

## 2. Decide first

| If you want… | Take this path | Cost |
|---|---|---|
| a product row with **live prices** | `productListing` here | look bounded by props; the strip cannot be recoloured (§7) |
| a product row with **any design** | own markup — `own-markup.md` | **prices and stock freeze** into the markup |
| a header/menu/footer unmistakably yours | own markup in a `chrome` group — `chrome.md` | sticky needs a workaround today |

Say the fork to the owner **before** building. The most expensive defect measured
on a real run was a fork nobody stated.

## 3. Where the truth about props lives

```
GET /docs/widgets          # public, no token, Cache-Control: max-age=300
```

Per kind: `label` (ru/en), `placement` (`chrome` | `page`), `render` (`slot` |
`section`), **`writePath`**, a JSON Schema of the props with defaults, and
`choices` — the allowed values of every enum.

It is **generated, not written**: `buildWidgetRegistry()` runs each widget's own
`manifest.normalize` on an empty object, so the reference reports what the parser
understands, with the defaults the renderer uses. `props: null` means no settings
of its own at all; `props: {}` means settings exist and all are optional.

Never write a prop from memory: **a value outside an enum is accepted and the
default is rendered**, as the reference states in its own `note`.

## 4. The kinds, as the live reference returns them

Seventeen, grouped by where their settings go. Counts are schema sizes — read
the schema itself before writing.

**Page blocks that draw themselves** (`render: section`, `writePath: props`):
`slideshow` 14 · `reels` 19 · `cards` 3 · `steps` 22 · `faq` 16 · `features` 16 ·
`cta` 15 · `columns` 9.

**Page blocks fed by the page** (`render: slot`) — three of them nest their
props, and that is where writes get lost: `productListing` → **`props.listing`**
(4) · `productCard` → **`props.card`** (1) · `form` → **`props.form`** (0, all
optional) · `breadcrumbs`, `pageHeading`, `html` → `props`, no props of their own
(`html` is configured as own markup — `own-markup.md`).

**Chrome** (`render: section`, `writePath: props`): `siteHeader` 32 ·
`siteMenu` 16 · `siteFooter` 13.

Containers are listed under `structural`, not widgets:

- **`group`** — `props.widgets`, an ordered list of block ids. The one form the
  platform stores composition in: a header, a footer, a body template and a page
  feed are all groups, told apart by the block's `group_type` label. See
  `chrome.md` §3.
- `heading` — legacy: fields sit on the section, not under `props`.
- `zone` (`props.refs` + the flags `hideSiteHeader` / `hideSiteFooter` /
  `hideSystemBlock`) and `chromeKit` (`props.kit` = `{top:[], bottom:[]}`) —
  **superseded by `group`**. Still read for shops that predate it; do not write
  new ones.

## 5. The write path is where agents go wrong

```
GET  /content-blocks          # → result[]: { id, name, sections[] }
PUT  /content-blocks/{id}     # { "id": …, "item": { "sections": [ … ] } }
```

```jsonc
{ "type": "text",                        // coarse legacy field: image|slideshow|text|video
  "payload": { "v": 2,
               "kind": "productListing",  // ← the widget's real identity
               "props": { "listing": { "categoryId": 12, "limit": 24 },
                          "blockWidth": "content" } } }
```

`type` is **not** the widget — it is validated against
`["image","slideshow","text","video"]` and says nothing about what is drawn.
`payload.kind` does.

**Props are nested per widget.** `writePath` for `productListing` is
`props.listing`, so the limit is `payload.props.listing.limit`. Writing
`payload.props.limit` is accepted and does nothing — the parser never looks
there. It misleads because **the read shape is not the write shape**: the schema
shows a flat `limit` because the parser returns flat. That is why `writePath`
exists at all.

**`sections` is a replace-set.** Read the block, change one element, send the
whole list back. Send a single section and the rest disappear: proto3 cannot
express "clear all", so a non-empty `sections` means "replace with exactly this".

So the only safe edit is read → change one leaf → `PUT` the whole list.
**Verify** by re-reading the block, asserting the leaf came back as sent, then
opening the page and counting the cards. A 200 is not evidence — see §8.

## 6. The wrapper is configurable too

Its axes are written **next to `kind`** — at the root of `props`, not inside the
widget's `writePath`:

| Axis | Type | Default |
|---|---|---|
| `props.blockWidth` | `"content"` \| `"full"` | `"content"` |
| `props.paddingSides` | `{top,right,bottom,left}` px | 12 vertical, 24 horizontal (0 horizontal when `full`) |
| `props.containerPadding` | `{top,right,bottom,left}` px | — |
| `props.blockRadius` | px or `{tl,tr,br,bl}` | 16 |
| `props.container` | `{fill, borderTop, borderBottom, borderColor}` | — |
| `props.innerMax` | px or `"content"` | — |
| `props.overflow` | `"visible"` \| `"clip"` | `"visible"` |
| `props.layer` | number (z-order) | unset |

Device layers are `props.tablet` and `props.mobile`, same keys; a missing layer
falls back to the wider one. Side effects: `overflow: "clip"` also breaks
`position: sticky` inside the block, `layer` makes the block positioned and can
clip a neighbour's dropdown, and `kind: "html"` gets zero padding and radius.

## 7. Known limits — say them out loud before promising a look

- `container.fill` on `productListing` is **accepted and does nothing**: the
  listing's own strip cannot be recoloured today;
- the listing template is `default` and there are no others — `oneOf ["default"]`,
  empty means the shop default, unknown reads as `default`;
- `siteMenu.level2` builds a real dropdown from the category tree, but it opens
  **on click** (`Section.tsx` toggles the active item in `onClick`). The schema
  description says "drop down on hover" — the description is wrong, not the code;
- a style value outside its enum falls back to the default instead of clamping
  toward it: `WEIGHTS` is `['400','500','600','700']`, so weight 800 on the
  header name renders as its default `600`;
- `siteMenu.labelStyle.size` in `kind: "text"` renders at `size + 2` — set 13,
  the browser computes 15px.

Past these bounds the answer is `own-markup.md` — and it costs live data in
product rows. Say so before building, not after.

## 8. Silently ignored

| What | What actually happens |
|---|---|
| a prop at the root of `props` for a nested widget (`props.limit`, not `props.listing.limit`) | accepted, stored, never read |
| a value outside an enum | accepted, the default is rendered |
| `container.fill` on `productListing` | accepted, the strip keeps its colour |
| an unknown `template` on `productListing` | accepted, reads as `default` |
| a weight outside `400/500/600/700` | accepted, the style's default weight is rendered |
| a `PUT` carrying one section out of several | the rest are deleted — not ignored, but just as silent |

## 9. Limits

- widget kinds **17** (+3 structural); reference ~53 KB, `max-age=300`;
- `sections` per content block: **max 50** (`validate.rules.repeated.max_items`);
- block `name`: 1–255 characters.

## 10. How this was verified

- 2026-08-19, live `GET https://api.vizen.shop/docs/widgets` → 200, 53 200 bytes;
  re-fetched and re-parsed 2026-08-20. §4 and §6 are copied from that response.
- Sources read: `vizen-market/storefront/scripts/gen-widgets-json.mjs`,
  `packages/sections/src/widgets/registry.ts`, `.../siteMenu/Section.tsx`,
  `packages/sections/src/siteChrome/config.ts`, `backend-3D/api/catalog/catalog.proto`.
- §7 items not traced to those sources come from the browser measurements of the
  storefront run of 2026-08-19; restated here unchanged.
