# Гайд веб-кодинга по API Vizen — как собрать страницу кодом

> Для ИИ-агента или разработчика с токеном: **сверстать HTML-страницу
> (лендинг, промо, квиз) и опубликовать её в магазине Vizen** — без админки,
> одними вызовами API. Все формы запросов ниже проверены живыми вызовами.
> Парный документ — структура ответов страниц:
> `vizen-market/app/docs/html-редактор/06-структура-страниц.md`.

> **Наполняешь каталог, а не верстаешь страницу?** Тебе нужен соседний гайд:
> **`GET /docs/catalog-import`** — товары, категории, характеристики, варианты и
> склейки, фото, публикация и откат. Там же решающая таблица «варианты внутри
> товара ↔ отдельные связанные товары» и подводные камни полей (валюта,
> основная и дополнительные категории, тройные флаги, пагинация).

**Какой путь твой — реши до чтения.**

| У тебя | Читай | Не читай |
|---|---|---|
| **готовая папка или сайт** — лендинг, экспорт генератора, сборка React/Vite, выгрузка из Figma/Webflow/Tilda | §2а «Готовая папка → страница», §2б «Лейаут = папка», область `GET /docs/transfer`, скилл `vizen-transfer` | §5, §7, §8а, §11.2 — они про html-блок уровня 1 в коробке и к папке уровня 3 не относятся |
| **html-вставка уровня 1** внутри страницы из виджетов платформы | §2–§8а, §11.2, §17 | — |
| **одна и та же вёрстка на МНОГИХ страницах с разными данными** | §2в «Компонент с параметрами», область `GET /docs/components` | — |
| формы и заявки | §13–§14 | — |

**Главное в одном абзаце (html-блок уровня 1).** Ты пишешь чистый HTML+CSS,
кладёшь его файлом в хранилище, оборачиваешь в «HTML-документ», а документ
вставляешь секцией в блок и привязываешь блок к странице. Твой HTML попадает в
СЕРВЕРНЫЙ html страницы (работает SEO) и рендерится в изоляции: твои стили не
сломают сайт, стили сайта не сломают тебя. Скрипты на уровне 1 сервер вырезает —
это не ошибка, это контракт (нужен JS — уровень 2, см. §8; **готовая страница
со скриптами — уровень 3, §2а: настоящий DOM, скрипты работают, изоляции нет**).

---

## 0. База, токен, префиксы

- **База:** прод `https://api.vizen.shop`, стенд `http://127.0.0.1:3940`.
- **Токен (PAT):** `Authorization: Bearer vz_pat_…` в каждом запросе — в том
  числе на публичных ручках (резолв витрины, чтение страницы по слагу): 403 там,
  где аноним получает 200, ты не поймаешь.
  Выпуск — админка магазина → `/api-access`, показывается один раз.
- **Первый вызов — паспорт ключа:** `GET /v1/account/token`. Отдаёт контур
  (`writes_to_live` — уйдут ли правки сразу на живой сайт), магазин с его
  валютой и адресом витрины, `capabilities` (что ключ реально может) и
  `warnings` — их передают владельцу целиком. Адрес витрины бери оттуда
  (`store.storefront_url`), а не выдумывай.
- **Нужные скоупы:** `catalog:read` + `catalog:write` + `storage:write`.
  Собираешь ЛИД-ФОРМУ (заявка/консультация/подписка) — нужны ещё
  `forms:read`/`forms:write`, а чтобы читать сами заявки — `leads:read`;
  весь сценарий отдельным разделом, **§13**.
- ⚠️ **`catalog:read` решает не «пустят ли», а ЧТО ты увидишь.** Без него ключ
  на публичных ручках работает, но отвечает ровно то же, что браузер без ключа:
  только опубликованное, без черновиков и скрытых категорий, всегда ПРОД —
  черновик дев-ключа тоже не покажется. Свою неопубликованную страницу такой
  ключ получит как `404 …_NOT_FOUND`: это не отказ в правах, а публичная
  проекция. Проверять свою работу ДО публикации можно только с `catalog:read`.
  Пишешь каталог (`catalog:write`) — бери и `catalog:read`, иначе не сможешь
  перечитать то, что создал.
- ⚠️ **Префиксы неоднородны:** каталог (`/products`, `/categories`,
  `/content-blocks`, `/html-documents`) — **без** `/v1`; хранилище и организация
  (`/v1/storages/files`, `/v1/storefronts/resolve`) — **с** `/v1`.
- Полная машиночитаемая схема: `GET /compiled.swagger.json`.

Проверка ключа: `GET /v1/account/token` → скоупы, `company_id`, срок. Это
единственный надёжный способ: публичные ручки отвечают 200 и без `catalog:read`
(публичной проекцией), так что «200 на `/categories`» ничего не доказывает.
`401` — ключ неверный или просрочен; на приватных ручках нехватка права видна
явно: `403 PAT_SCOPE_MISSING (required_scope: …)`.

### ⚠️ Типы `id` в ответах разные — не удивляйся

| Сущность | Что вернёт `result.id` |
|---|---|
| html-документ (`POST /html-documents`) | **строка**: `"12"` |
| контент-блок (`POST /content-blocks`) | **строка**: `"139"` |
| категория/страница (`POST /categories`) | **число**: `45` |
| товар (`GET /products/{id}`) | число |

Причина — историческая (int64 в JSON сериализуется строкой). В запросах можно
слать число: путь `/{id}` авторитетнее тела. Сравнивая id, приводи к строке.

## 1. Сначала прочитай магазин (иначе страница будет выдуманной)

| Что нужно | Запрос |
|---|---|
| Магазин, валюта, логотип, язык, контакты | `GET /v1/storefronts/resolve?slug={магазин}` (публично) |
| Дерево категорий и страниц | `GET /categories?company_id={id}` — в ОДНОМ списке вперемешку: `type:"product"` (категория каталога), `type:"page"` (страница), `type:"news"` (рубрика новостей); фильтруй по `type` |
| Товары (цены, слаги, картинки) | `GET /products?filter.company_id={id}&page.number=1&page.limit=24` |
| Статьи/новости | `GET /articles?company_id={id}&page.number=1&page.limit=10` (⚠️ `page.number` обязателен, ≥ 1) |
| Свои html-документы | `GET /html-documents` |
| Что уже на странице | `GET /categories/by-slug/{slug}?company_id={id}` |

⚠️ **Фильтры товаров пишутся с префиксом `filter.`, и это не мелочь.** Параметр
без префикса ПРОГЛАТЫВАЕТСЯ МОЛЧА: `GET /products?category_id=168` вернёт 200 и
ВЕСЬ каталог магазина, а не раздел. Ошибки не будет — будут не те данные, и по
ним соберётся не та страница.

| Фильтр | Что делает |
|---|---|
| `filter.category_id` | товары раздела (главный фильтр витрины) |
| `filter.ids` | несколько товаров по id разом |
| `filter.query` | поиск по названию |
| `filter.is_published` | только опубликованные |
| `filter.min_price` / `filter.max_price` | ценовой диапазон |
| `page.number` / `page.limit` | страницы; `sort.field` / `sort.order` — порядок |

Полный список — `GET /compiled.swagger.json`. Правило общее: **сверяй, что
ответ изменился после добавления фильтра.** Одинаковый `total` до и после — знак,
что параметр не понят.

Ссылайся на РЕАЛЬНЫЕ слаги и товары: URL товара — цепочка слагов его основной
категории + слаг товара (`/kombo-nabory/kombo`), URL страницы — цепочка слагов
категории-страницы (`/m1-smoke`).
⚠️ У товара `seo.slug` может быть ПУСТЫМ — тогда витрина адресует его по id:
`/{слаг-категории}/{id}`. Правило: `seo.slug || String(id)`, иначе получишь
битые ссылки.

### ⚠️ Конверты ответов разные — смотри внимательно

| Ручка | Форма ответа |
|---|---|
| `GET /categories` | `{"result": [ … ]}` — массив прямо в `result` |
| `GET /products` | `{"result": {"items": […], "total": N, "currentPage": N}}` |
| `GET /articles` | `{"items": […], "total": N, "current_page": N}` — БЕЗ `result` |
| `GET /products/{id}`, `/categories/by-slug/…` | `{"result": {…}}` + сиблинги (`content_blocks`, `categories`, …) |

Ещё одно расхождение: в СПИСКЕ товаров слаг лежит плоско (`item.slug`), а в
ДЕТАЛИ — внутри seo (`result.seo.slug`). Правило адресации то же: слаг или, если
он пуст, `id`.

### Весь JSON страницы одним запросом

Чтобы собрать уникальную страницу товара/категории/новости, читай её целиком —
в ответе приходит и сама сущность, и её блоки:

```bash
GET /categories/by-slug/{slug}?company_id={id}   # страница или категория
GET /products/by-slug/{slug}?company_id={id}     # товар (+categories, +отзывы, +варианты)
GET /articles/by-slug/{slug}?company_id={id}     # статья (+related)
```

Что внутри и как этим пользоваться:

| Ключ ответа | Что даёт для генерации |
|---|---|
| `result` | имя, описание, цена, слаг, SEO-поля, картинки (`preview` + `galleries[]` — привязанные галереи с элементами; набор варианта ссылается на галерею через `variants_info.variants[].gallery_id`) |
| `content_blocks[]` | ВСЕ блоки страницы: `sections[].payload.kind` — вид блока, `props` — его настройки. Оттуда берёшь тексты, картинки, ссылки, чтобы переиспользовать их в своей вёрстке |
| `content_blocks[]` с `name:"__page:top"` | оглавление зоны: `refs` (порядок блоков) и `page` (флаги — скрыта ли шапка/подвал/крошки) |
| `categories` (у товара) | цепочка для крошек и ссылок |
| `attribute_sections`, `reviews_summary`, `variants_info`, `group_info` | характеристики, рейтинг, варианты — материал для карточки |

Правило: **сначала прочитай страницу, потом генерируй.** Так твой блок будет
ссылаться на реальные товары и картинки магазина, а не на выдуманные, и не
повторит то, что уже есть на странице.

Что брать из товара для карточки на своей странице (живой пример ответа):

```jsonc
"name":  "Кровать двухъярусная лофт Волстрит 120x90",
"price": { "price": 61192, "old_price": 71991, "currency": "RUB",
           "promotion_name": "Кроватная распродажа −15%" },   // ЦЕЛЫЕ рубли
"seo":   { "slug": "krovat_dvukhyarusnaya_loft_volstrit_120x90_nebula" },
"category_id": 4,
"preview": { "id": "76a176b2-…", "url": "/files/sunset/view/content/76/a1/76a176b2-….webp" },
"gallery_ids": ["31"],                                          // ссылки на галереи магазина
"galleries": [                                                  // содержимое; может быть пустым
  { "id": "31", "name": "Синие", "source": "images", "canvas_id": "0",
    "items": [ { "item_key": "8231…", "kind": "image",           // kind: image|video|audio|html
                 "file": { "id": "…", "url": "/files/…" }, "caption": "" } ] }
]
```

Цену выводи как есть (это целые единицы валюты, не копейки), валюту — из
`price.currency` или из `storefront.currency`. Картинку товара бери из
`preview.url` или из `galleries[].items[].file.url` (у элементов с
`kind: "image"`) — это и есть переиспользование (§3). Галерея — отдельный
ресурс магазина (`/galleries`), одна и та же может стоять на нескольких
товарах; в СПИСКАХ (`GET /products`) её нет, там только `preview`.

⚠️ **Цена: источник истины — детальная ручка `GET /products/{id}`.** У списка
`GET /products` и у карточки цена одного товара может отличаться, и это не
ошибка: у комбо-наборов (`combo_items` непустой) цена в карточке ВЫЧИСЛЯЕТСЯ из
компонентов, а акции при этом не применяются. Пример живого расхождения для
комбо: список `57 367 ₽ (акция −15%)`, карточка `67 491 ₽`. Чтобы цена на твоей
странице совпала с той, что покупатель увидит в карточке, бери её из
`GET /products/{id}`, а не из списка.

## 2. Создай HTML-документ

```bash
POST /html-documents
{ "item": { "name": "Промо-лендинг весна", "level": 3 } }
# → { "result": { "id": 10, … } }
```

`level`: **3** — своя вёрстка в настоящем DOM страницы (скрипты исполняются,
`position: fixed` работает, контент индексируется; роль owner/admin, серверный
флаг на проде включён) — **уровень любой готовой страницы или папки** (§2а;
`transfer.mjs` создаёт документ уровня 3 сам); **1** — html-вставка внутри
страницы из виджетов (санитайз, Shadow DOM, без скриптов; §5–§8а);
**2** — скрипт в iframe-песочнице без SEO (§8). Пусто = 1 — дефолт вставки,
не переноса. Отказал сервер на уровне 3 — сообщи владельцу, на уровень 1 не
откатывайся: это другая модель.

## 2а. Готовая папка → страница сайта (HTML-проект, уровень 3)

### ЕДИНОЕ ПРАВИЛО СБОРКИ (владелец, 2026-09-03) — читать первым

Страница делается как обычный сайт в папке и переносится **одной командой**;
никаких пересборок, ручных заливок файлов и переписывания путей.

1. **Виджет = папка.** `index.html` в корне + `styles.css` + `app.js` +
   `assets/…` в подпапках. Все пути относительные — **от корня папки**. На
   сайте файлы отдаются с корня виджета `/_html/{doc}/{release}/…` с той же
   вложенностью; страница сайта в адресации не участвует. Мусор (макеты,
   превью, README) — в `.vizenignore`.
2. **`index.html` — чистая вёрстка:** разметка + `<link>/<style>/<script>`,
   **без** `<!doctype>/<html>/<head>/<body>`. Прислали целую страницу —
   не страшно, платформа сама уберёт обёртку при публикации.
3. **Скрипт, который строит пути сам** (`img.src = …`, `fetch(…)`), берёт
   корень виджета — свой у каждого проекта:
   `const base = document.currentScript?.closest('.vz-body')?.dataset.vzBase ?? window.VZ_ASSET_BASE ?? ''`
   (inline-скрипт); `img.src = base + 'assets/x.jpg'`.
   Локально из папки `base` пустой, на сайте — корень виджета. Внешний или
   `defer`-скрипт `document.currentScript` уже не имеет — там
   `document.querySelector('.vz-body-<id>')?.dataset.vzBase` или
   `window.VZ_ASSETS[<id>]`, где `<id>` — id документа из ответа API.
4. **Локально** открывай через `npx serve .` (не `file://`). Он показывает
   голую папку — без шапки и футера сайта, без его каскада и без подстановок
   `vz-`; точный предпросмотр — публикация дев-ключом (`<slug>--dev`).
   Что изменится на сайте — подраздел «Каскад витрины и рантайм» ниже.
5. **Перенос:** `node backend-3D/tools/html-transfer/transfer.mjs ./папка --page <slug>`
   (Node 18+, ключ в `VIZEN_TOKEN`; стенд — `--api http://127.0.0.1:3970`)
   Первый запуск создаёт документ уровня 3, релиз и блок на странице; каждый
   следующий заливает **только изменившиеся файлы** и сразу обновляет страницу.
   ZIP одним запросом `POST /html-documents/{id}/releases/zip?publish=1` —
   только релиз: документ создай заранее (`POST /html-documents {level:3}`),
   блок на страницу смонтируй сам («Тот же путь руками» ниже).
   Ошибки валидатора — исправить и запустить снова; откат — `--rollback`.
6. **Демо-шаблон** = та же папка. Новая страница из шаблона — копия папки и
   `--page другой-slug`; версия — релиз; правка — файл + повтор команды.
7. **Проверка после переноса** (обязательна): открой `url` из ответа
   в браузере (headless — тоже браузер), сними скриншот десктоп +
   мобильный, посмотри консоль: ни ошибок JS, ни 404 по файлам; сравни с
   локальной версией. Расхождение — правь папку и повтори п. 5.
8. **Не делать:** абсолютные URL вместо относительных, резать страницу на
   блоки, iframe/shadow «для изоляции», заливать файлы по одному через
   storage-API, класть в релиз вторую `.html`-страницу.

Порядок работы чата: прочитал ключ (`/v1/account/token`) → собрал страницу
локально в папке → одна команда переноса → открыл адрес, скриншот, консоль →
отчёт владельцу со ссылкой.

Если страница уже свёрстана (лендинг, выгрузка из нейросети) —
**не переписывай пути и не режь HTML на блоки**: папка переносится как есть.
Правило платформы: «`<head>` убираем, все привязки CSS/JS остаются» — папка
встаёт в настоящий DOM страницы, без Shadow DOM и iframe.

**Граница: один виджет = одна html-страница** (`index.html`). Лишние `.html`
в релизе — предупреждение `page.extra`, шлюз их не отдаёт. Многостраничник —
несколько виджетов на нескольких страницах сайта.

### Стандарт приёма: обёртка одна — сайта

На сайте уже есть страница с шапкой и футером; твоя вёрстка встаёт в неё
**без iframe и без shadow DOM**, скрипты работают, картинки открываются.
Поэтому нормальный вход — **чистая вёрстка** (формат 1):

```html
<link rel="stylesheet" href="styles.css">
<style>.hero{…}</style>
<section class="hero"><img src="assets/hero.webp" alt="…"></section>
<script src="app.js" defer></script>
```

— без `<!doctype>`, `<html>`, `<head>`, `<body>`. Именно так присылай
`index.html`, если делаешь страницу под Vizen с нуля.

Формат 1 не «второй сорт»: при публикации он тоже получает обёртку
`<div class="vz-body vz-body-{doc}">…</div>` — ту же, что и целая страница.
Именно на ней сходятся твои `body/html/:root` из CSS (см. ниже), и именно она
несёт корень файлов виджета. Повторная публикация второй обёртки не добавляет.

**Целая страница** (формат 2: есть `<html><head><body>`) тоже принимается —
при публикации платформа приводит её к формату 1 сама:
- из `<head>` остаются только `<link>`, `<style>`, `<script>`, `<noscript>` →
  `<div class="vz-head vz-head-{doc}" hidden>…</div>`;
- `<title>`, `<meta>`, `<base>` выбрасываются; `title` и `description`
  уходят в SEO страницы сайта (если её поля пусты);
- `<body class="dark" data-x="…">` → `<div class="vz-body vz-body-{doc} dark" data-x="…">…</div>`;
- твои селекторы `body { … }`, `html { … }`, `:root { … }` (в `<style>` и в
  `.css` файлах) переписываются на `.vz-body-{doc}` — стили ложатся на твою
  обёртку, а два проекта на одной странице не пересекаются.

Хранится **только** чистый результат: конвертер пишет его на место
`index.html` релиза, оригинал не хранится; `source_file_id` документа
указывает на этот файл, его видно и правится в админке («Код страницы»).
`{doc}` — id документа. Правка в админке = новый релиз (окно «Код страницы»
сохраняет через `POST …/releases {base:"active"}` → `PUT` → `publish`);
прямая замена файла релиза через `ReplaceFile` запрещена — `FILE_IN_RELEASE`.
Уровень проекта зафиксирован (3), панель «Файлы блока» скрыта — файлы живут в
релизе.

### Правила папки (их проверяет валидатор при публикации)

1. `index.html` в корне — единственная страница виджета; другие `.html` в
   релизе — предупреждение `page.extra` (шлюз HTML не отдаёт).
2. **Все пути относительные, от корня папки и внутри неё.** `../что-то` за
   корень — ошибка `ref.outside`; файл, которого нет, — `ref.missing`.
   Строковые пути к файлам в `.js` без `window.VZ_ASSET_BASE` —
   предупреждение `js.assets` (на сайте такой путь считается от URL
   страницы, а не от корня виджета). Читаешь корень через `dataset.vzBase` —
   держи `window.VZ_ASSET_BASE` в фолбэке, тогда предупреждения не будет.
3. `<head>` не нужен: при публикации из него остаются только привязки
   (`link/style/script/noscript`), `title`/`meta description` уходят в SEO
   страницы сайта, остальное (`<base>`, `og:*`, `http-equiv`, `manifest`)
   выбрасывается (предупреждение `head.stripped`).
4. Внешние ресурсы (шрифты Google, CDN) — только `https://`; `http://` —
   ошибка `ref.http`. Лучше положить шрифты в папку.
5. Картинки готовь нужного размера сам: к файлам проекта нарезка `/w/…` не
   применяется (они отдаются байт-в-байт). Рекомендация ≤ 400 КБ и `width/height`
   у `<img>` — иначе предупреждения `img.large` / `img.nosize`.
6. Лимиты: ≤ 500 файлов, HTML-файл ≤ 2 МБ, любой файл ≤ 50 МБ.
7. Нельзя: серверные исполняемые (`php/py/sh/exe…`) — `file.forbidden`;
   `navigator.serviceWorker.register` — ошибка `sw.register` (воркер с
   корня перехватил бы весь магазин, включая оплату).
8. Ссылки на сайт — абсолютный путь (`/catalog/x`) или `vz:page/<id>`;
   формы — `href="form:<id>"` (§13). `<form>` без обработчика —
   предупреждение `form.nohandler`.
9. В папку не класть: макеты, `previews*/`, `originals/`, `README`, `qa.mjs`
   — `.vizenignore` в корне (синтаксис как у `.gitignore`); скрытые файлы
   (`.DS_Store`, `.git/`) пропускаются молча.
10. Один проект = одна страница сайта. Локально открывай через HTTP-сервер
    (`npx serve .`), не `file://`.

### Пути на сайте: виджет = папка, режим один

Виджет — блок внутри страницы сайта (шапка/футер/другие блоки вокруг);
обёртка одна — сайта, других режимов нет. Все файлы релиза отдаются с корня
виджета `/_html/{doc}/{release}/…` с той же вложенностью, что в папке:

- пути в разметке и CSS (`src`, `href`, `srcset`, `url(…)`, `@import`)
  переписываются на этот корень при выводе — в папке ничего не менять;
- пути, которые строит скрипт, — через корень виджета. `/content` отдаёт его
  двумя способами: атрибутом `data-vz-base="/_html/{doc}/{rid}/"` на обёртке
  `.vz-body-{doc}` и первым узлом
  `<script>window.VZ_ASSET_BASE="…";(window.VZ_ASSETS=window.VZ_ASSETS||{})["{doc}"]="…"</script>`.
  Рекомендованный способ — читать его у **своей** обёртки:

  ```js
  // inline-скрипт внутри виджета
  const base = document.currentScript?.closest('.vz-body')?.dataset.vzBase ?? window.VZ_ASSET_BASE ?? ''
  // внешний или defer-скрипт (currentScript уже null), 51 — id документа
  const base51 = document.querySelector('.vz-body-51')?.dataset.vzBase ?? window.VZ_ASSETS?.[51] ?? ''
  ```

  `window.VZ_ASSET_BASE` — «когда виджет на странице один»: два виджета
  перезаписывают глобал друг другу, и отложенный скрипт первого прочитает
  корень второго. Без корня путь считается от URL страницы и даёт 404.

Блок уровня 3 рисуется **без коробки** платформы (нет `.vz-box`/отступов/
радиуса); нужна коробка — `props.html.boxed: true`. При переносе на страницу
старые html-блоки (в т.ч. прежние iframe-переносы) снимаются — проект на
странице один. Дев-ключ публикует в черновик: страница видна на
`<slug>--dev.<zone>` под сессией владельца (витрина зовёт `/content` с
заголовками контура); прод-ключ — на живой сайт.

### Каскад витрины и рантайм — что изменится на сайте по сравнению с `npx serve`

Виджет уровня 3 — настоящий DOM страницы, стилевой изоляции нет (так задумано:
автор владеет страницей). Значит:

- **На вёрстку ложится каскад витрины.** Витрина грузит Tailwind preflight
  (`@layer base`): `*{margin:0;padding:0;border:0 solid}`,
  `h1…h6{font-size:inherit;font-weight:inherit}`, `a{color:inherit;text-decoration:inherit}`,
  `ol,ul{list-style:none}`, `img,svg,video{display:block;max-width:100%;height:auto}`,
  `button,input{font:inherit;background:transparent;border-radius:0}`.
  Твой CSS вне слоёв и при равной специфичности побеждает — всё, что задано
  явно, сохранится; всё, что оставлено браузерным дефолтам (размер заголовков,
  маркеры списков, подчёркивание ссылок, вид кнопок), сбросится. Клади свой
  base.css под корневым классом проекта. Шрифт страницы — `ui-sans-serif,
  system-ui` 16 px; твой `body{font-family}` ляжет только на `.vz-body-{doc}`.
- **`body`, `html`, `:root`** в `<style>` и в `.css` релиза переписываются на
  `.vz-body-{doc}` (составные `body.dark` — нет). `body{overflow:hidden}` из
  скрипта модалки скролл страницы не запрёт — запирай свой корень.
- **Скрипты исполняются при разборе страницы и повторно после перехода по
  ссылке внутри сайта** (платформа пересоздаёт узлы `<script>` виджета). Пиши
  код, который работает «прямо сейчас», и переинициализируйся по событию
  `vz:navigate` на `document` (`detail.path`); одного `DOMContentLoaded` мало —
  после клика он уже не наступит.
- **Запретные зоны** `/cart`, `/checkout`, `/account`, `/wishlist`, `/orders`,
  `/deals`, `/dashboard`: разметка шапки есть, скрипты не исполняются — и
  относительный `<link rel="stylesheet">` виджета вырезается вместе с ними
  (замер на стенде 2026-09-12: шапка на `/cart` без своего `header.css`).
  Inline `<style>` остаётся; CSS папки лейаута подключён в `<head>` везде.
  Значит: стили шапки — в папку лейаута или inline.
- **Геометрия.** У страницы с виджетом уровня 3 без коробки `<main>` снимает
  containment: `position: fixed`, `100vw`, `100dvh` считаются от окна, как в
  обычном сайте (рейл `fixed` в `vezu-vezu` — во всю высоту окна). Липкая шапка
  — всё равно `vz-sticky`: `fixed` не резервирует высоту.
- **Файлы релиза отдаются байт-в-байт** (`/w/` к ним не применяется) — размер
  картинок готовь сам; картинки магазина в `<img src>` сервер переписывает на
  нарезку сам.
- **CSS из html-виджета шапки ложится на всю страницу** (light DOM; карточка
  товара `vezu-vezu` берёт шрифт из `global.css` шапки). Общие стили и шрифты
  всё равно кладут в папку лейаута (§2б): кадр редактора грузит только `<head>`
  лейаута, лейаут подключается до первого кадра, в запретных зонах он
  единственный, кто доезжает, а снятая с раздела шапка уносит стили с собой.

Полный контракт с таблицей «что едет 1:1 / перепривязывается / не едет» и
заметками по React/Vite, Tailwind и экспортам — `GET /docs/transfer`.

### Самый короткий путь — скрипт

Скрипт лежит в репо `backend-3D`: `tools/html-transfer/transfer.mjs`
(Node 18+, зависимостей нет; README рядом). Нужны скоупы `catalog:write` +
`storage:write` (+ `catalog:read` для `--list`) и роль owner/admin в магазине.
Страница `--page <slug>` должна **уже существовать** (`type=page`; slug —
из `GET /categories`, создать — `POST /categories`).

```bash
VIZEN_TOKEN=vz_pat_… node backend-3D/tools/html-transfer/transfer.mjs ./my-site --page <slug-страницы>
# повтор = новый релиз, заливаются ТОЛЬКО изменившиеся файлы (дедуп по sha256;
# index.html заливается всегда — его меняет конвертер)
node transfer.mjs --doc <id> --list      # релизы
node transfer.mjs --doc <id> --rollback  # откат на предыдущий (только указатель, без новых файлов)
node transfer.mjs ./my-site --page <slug> --api http://127.0.0.1:3970   # стенд
```

В ответе — `url` (адрес страницы; для дев-ключа `<slug>--dev.<zone>`),
`path`, `embed_prefix` (= корень файлов виджета), `seo`.

### Тот же путь руками (API)

```bash
# 1) проект (уровень 3)
POST /html-documents  { "item": { "name": "Лендинг курса", "level": 3 } }   # → id

# 2) манифест релиза: сервер отвечает, ЧТО заливать (известные хеши переиспользуются)
POST /html-documents/{id}/releases
{ "base": "active",                     # "active" (по умолчанию) | "none" | <release_id>
  "files": [ { "path": "index.html", "sha256": "<hex64>", "size": 12345 }, … ],
  "delete": [ "old.png" ] }
# → 201 { "release_id", "upload": [ { "path", "file_id", "upload_url" } ], "reused": [...], "inherited": [...] }

# 3) байты — только для upload[] (PUT без Authorization)
PUT <upload_url>

# 4) публикация: подтверждает загрузки, валидирует, конвертирует целую страницу
#    в чистую вёрстку, атомарно делает релиз активным
POST /html-documents/{id}/releases/{rid}/publish
# → 200 { "release_id", "url", "path", "embed_prefix", "seo": { title, description }, "report": { errors, warnings, info } }
# → 422 RELEASE_INVALID { report }  — исправь ошибки, собери новый релиз с base:<rid>
# → 409 FILES_PENDING { pending }   — долей файлы и повтори
POST /html-documents/{id}/releases/{rid}/validate   # то же без публикации

# 5) монтирование на страницу: html-блок уровня 3 (страница type=page должна существовать)
POST /content-blocks { "item": { "name": "…", "sections": [ { "type": "text", "files": [],
  "payload": { "v": 2, "kind": "html", "props": { "html": { "html_document_id": <id>, "level": 3 } } } } ] } }
PUT /categories/{page_id}/content-blocks { "id": <page_id>, "items": [ { "block_id": <block>, "sort_order": 1 } ] }

GET  /html-documents/{id}/releases          # список, активный, path страницы
POST /html-documents/{id}/rollback          # { "release_id"? } — предыдущий опубликованный; только указатель
```

**ZIP без скрипта** (полный путь, если репо `backend-3D` нет под рукой):

```bash
# 1) документ уровня 3
POST /html-documents  { "item": { "name": "Лендинг курса", "level": 3 } }   # → id
# 2) архив папки одним запросом — сервер сам считает хеши, дедупит, валидирует и публикует
curl -X POST "https://api.vizen.shop/html-documents/{id}/releases/zip?publish=1" \
     -H "Authorization: Bearer $VIZEN_TOKEN" -F file=@site.zip     # или Content-Type: application/zip + --data-binary
# папка-корень внутри архива (site/index.html) разворачивается сама; лимит архива 60 МБ
# 3) блок kind:"html" с props.html.{html_document_id, level:3} на страницу — п. 5 выше
```

Дев-ключ публикует релиз в черновик магазина (страница видна на
`<slug>--dev.…`), прод-ключ — на живой сайт. Откат и активация переключают
только указатель — новых файлов не создают. Документ больше 2 МБ на выдаче —
`FailedPrecondition HTML_DOCUMENT_TOO_LARGE`.

### Один механизм — релиз. Правка опубликованной страницы без перезаливки

Все входы (скрипт, ZIP, ручной манифест, окно «Код страницы» в админке)
сходятся в **один механизм — релиз**. Владелец просит «поменяй тут текст» —
чат обязан уметь поправить уже опубликованную страницу, не перезаливая
проект:

```bash
# 0) id документа: по имени из списка компании …
GET /html-documents                                   # → result[] {id, name, level, …}
#    … или из блока страницы
GET /categories/by-slug/{slug}?company_id=<N>         # → content_blocks[].sections[].payload.props.html.html_document_id

# 1) текущая чистая вёрстка (index.html активного релиза, уже без head/body)
GET /html-documents/{id}                              # → result.source.url — скачать текст

# 2) поправить разметку локально

# 3) частичный релиз: только index.html, остальное наследуется из активного
POST /html-documents/{id}/releases
{ "base": "active", "files": [ { "path": "index.html", "sha256": "<hex64>", "size": N } ], "note": "текст в hero" }
PUT <upload_url>                                      # байты index.html
POST /html-documents/{id}/releases/{rid}/publish      # страница обновилась; откат — POST …/rollback
```

Заменить/добавить один файл (картинку) — тот же вызов с этим путём в
`files`; удалить — `"delete": ["assets/old.png"]`. Окно «Код страницы» в
админке делает ровно этот частичный релиз. `index.html` **никогда не
дедупится** (конвертер меняет его в релизе) — это норма, не ошибка;
`source_file_id` документа всегда указывает на `index.html` активного релиза.

## 2б. Лейаут сайта = папка (общий CSS, шрифты и код на всех страницах)

**Где живёт общий CSS: не в шапке, а в лейауте.** Стили, шрифты и скрипты,
положенные внутрь html-виджета шапки, видит только сама шапка: остальные
виджеты, тело страницы и кадр редактора живут снаружи её изоляции. Всё, что
принадлежит САЙТУ, а не одному блоку, кладётся в папку ЛЕЙАУТА — она ложится на
каждую страницу магазина.

**Лейаут** — группа-контейнер `group_type:"theme"` (в UI «Лейаут»). Их может
быть несколько: у раздела бывает свой дизайн. Какой лейаут у страницы, решает
слот `theme` по лестнице «страница → её разделы → магазин → самый ранний
лейаут». Внутри лейаута выбираются шапка/лента/тело/футер
(`PUT /design/bindings/theme/{id}`, подробно — `GET /docs/chrome` §3.6), а
файлы и код — этот раздел.

### Как узнать id лейаута

```bash
# серверного фильтра у списка блоков нет — фильтруй ответ у себя
curl -s -H "Authorization: Bearer $VZ" $API/content-blocks \
  | jq '[.result[] | select(.group_type=="theme") | {id, name}] | sort_by(.id)'
# «Основной» = самый ранний (минимальный id) — это же правило у резолва по умолчанию
```

### Что кладётся в папку

Папка устроена как у виджета (§2а): `index.html` в корне, файлы в подпапках,
пути относительные, релизы · откат · дев-контур — тот же один механизм. Меняется
смысл `index.html`: у виджета это контент, у лейаута — **скелет сайта**.

| В папке | Куда уезжает |
|---|---|
| `<head>` скелета | узлы `meta`/`link`/`style` встают настоящими тегами в `<head>` **каждой** страницы; `script`/`noscript` едут сырым HTML |
| `class` у `<body>` | на корень страницы (рядом с классом скоупа) |
| содержимое `<body>` | код перед `</body>` каждой страницы |
| `<title>`, `<base>`, `<meta http-equiv>`, `<link rel=manifest>` | вырезаются при публикации: заголовок принадлежит странице, остальное — платформе |
| любой `*.css` | селекторы `html`, `body`, `:root` скоупятся на `.vz-theme-{doc}` — класс КОРНЯ страницы |
| `icon.png` (`.jpg`/`.jpeg`/`.webp`) | догенерируются `icon-32.png`, `icon-180.png`, `icon-192.png`, `icon-512.png` и `favicon.ico`; свои файлы с теми же именами не перетираются, `icon.svg` берётся как есть |
| остальные файлы | отдаются с корня `/_html/{doc}/{rid}/…`; скрипты берут корень из `window.VZ_THEME_BASE` |

**`body { … }` и `:root { … }` писать можно** — они лягут на корень страницы, и
это штатный способ задать сайту шрифт, фон и переменные. Составные селекторы
(`body.dark`, `body[data-x]`) НЕ переписываются намеренно: класс на настоящий
`<body>` ставит твой скрипт, и правило обязано остаться там же.

Скелет разбирается **один раз при публикации** и хранится собранным в строке
релиза — витрина HTML не парсит. SEO из скелета не извлекается: один `<title>`
на весь сайт был бы прямой потерей выдачи.

### Ручки папки лейаута

Скоупы и роль — те же, что у релизов виджета: `catalog:write` + `storage:write`
(+ `catalog:read` на чтение), роль owner/admin в магазине.

| Метод | Путь | Что делает |
|---|---|---|
| `GET` | `/themes/{id}/site` | состояние папки: `{contour, theme_id, document_id, active_release_id, base, preview_base?, scope_class, files[], head_source, foot_source, head[], head_html, foot_html, body_class, icons[], icons_generated[], limits}`. Папки нет → `{document_id:0, files:[]}`; **GET ничего не создаёт** |
| `POST` | `/themes/{id}/site/releases` | черновой релиз из манифеста `{base, files[{path,sha256,size}], delete[], note}` → `{release_id, upload[], reused[], inherited[]}` |
| `POST` | `/themes/{id}/site/releases/zip` | релиз из ZIP (`?publish=1`, `?base=`) |
| `POST` | `/themes/{id}/site/releases/{rid}/publish` | валидатор → чистка скелета → фавиконки → разбор скелета в строку релиза → переключение указателя. Ответ `{release_id, base, scope_class, icons_generated[], report}`; `url`/`path` нет — лейаут не стоит на одной странице |
| `POST` | `/themes/{id}/site/releases/{rid}/validate` | то же без публикации |
| `GET` | `/themes/{id}/site/releases`, `/themes/{id}/site/releases/{rid}` | список релизов с активным / один релиз с картой файлов |
| `POST` | `/themes/{id}/site/rollback` | откат `{release_id?}` — по умолчанию предыдущий опубликованный; только указатель |

```bash
# ZIP одной командой: папка → релиз → публикация
curl -X POST "$API/themes/118/site/releases/zip?publish=1" \
     -H "Authorization: Bearer $VZ" -F file=@theme.zip
# → { "release_id": 42, "base": "/_html/77/42/", "scope_class": "vz-theme-77",
#     "icons_generated": ["icon-32.png","icon-180.png","icon-192.png","icon-512.png","favicon.ico"],
#     "report": { "errors": [], "warnings": [], "info": [] } }

curl -s -H "Authorization: Bearer $VZ" $API/themes/118/site | jq '{base, scope_class, body_class, icons_generated}'
```

Скриптом — то же одной командой (`--theme` и `--page` вместе не бывают: лейаут
не монтируется на страницу):

```bash
node backend-3D/tools/html-transfer/transfer.mjs ./theme --theme 118
node backend-3D/tools/html-transfer/transfer.mjs --theme 118 --list
node backend-3D/tools/html-transfer/transfer.mjs --theme 118 --rollback
```

### Привязать лейаут и оформить системные страницы

```bash
# весь сайт
PUT /design/bindings/shop/0          { "slots": { "theme": 118 } }
# один раздел (и всё, что под ним)
PUT /design/bindings/category/648    { "slots": { "theme": 118 } }
# системная страница: 1 = cart (реестр ниже), слоты только theme|header|footer
PUT /design/bindings/system/1        { "slots": { "theme": 118, "header": 1975 } }
# что фактически применится
GET /design/system/cart?company_id=<N>    # публичная: { "design": { "page": { header, footer, theme_id, … } } }
```

Реестр системных страниц (номер = `resource_id`, номера не переиспользуются):
`cart=1 · checkout=2 · account=3 · orders=4 · wishlist=5 · search=6 · deals=7 ·
dashboard=8 · coupons=9 · notfound=10`. Без своих привязок они наследуют лейаут
и хром магазина.

### Твой код на странице: `vz:navigate` и запретные зоны

- переходы внутри сайта — без полной перезагрузки. После каждого перехода **и
  после первой загрузки** на `document` летит
  `CustomEvent('vz:navigate', { detail: { path } })` — по нему переинициализируй
  свой код. На `DOMContentLoaded` не рассчитывай: после клика документ уже
  загружен;
- переход на страницу с ДРУГИМ лейаутом = полная перезагрузка (стили двух
  лейаутов не смешиваются);
- **запретные зоны** — `/cart`, `/checkout`, `/account`, `/wishlist`, `/orders`,
  `/deals`, `/dashboard` (и всё вложенное). Там не исполняются ни скрипты
  лейаута (`head_html`/`foot_html`, `window.VZ_THEME_BASE`), ни скрипты
  html-вёрстки уровня 3: это деньги и персональные данные. Стили, классы и
  разметка остаются — корзина выглядит своей страницей, но не исполняет чужой
  код. Рассчитывать на скрипт в корзине нельзя.

### Стабильные адреса файлов сайта: `/_site/<путь>`

`/_html/{doc}/{rid}/…` версионирован намеренно: релиз неизменяем, поэтому такой
адрес можно кэшировать вечно. Ровно это делает его непригодным там, где ссылка
живёт ДОЛЬШЕ релиза — письмо покупателю, подпись в почте, карточка организации,
чужой сайт: вписанный туда `/_html/51/318/logo.svg` станет 404 при первой же
публикации папки.

```
GET /_site/logo.svg          # тот же файл АКТИВНОГО лейаута, без номера релиза
GET /_site/favicon.ico
```

Резолв тот же, что у шапки сайта: слот `theme` магазина → самый ранний лейаут →
его папка → активный релиз. Отличий от `/_html/` три, и все — следствие
отсутствия номера релиза:

1. кэш `public, max-age=300` + ETag вместо immutable (публикация обязана
   доезжать до покупателя сама);
2. **заголовок арендатора обязателен** — адресация начинается с хоста, без него
   «активный лейаут» не определён: прямой запрос к API даст 404;
3. HTML-страницы релиза не отдаются (как и в `/_html/`).

Скоуп CSS тот же — `.vz-theme-{doc}`. Правило простое: внутри сайта ссылайся на
`/_html/{doc}/{rid}/…`, наружу отдавай `/_site/…`.

### Лейаут у статьи

Статья — такой же ресурс оформления, как товар или раздел:

```bash
PUT /design/bindings/article/<id>    { "slots": { "theme": 1837 } }
```

Лестница: `статья → её рубрика и предки рубрики → ЛЕЙАУТ → магазин → дефолт`.
Ветка наследования — `category`, а не `category_children`: статья лежит в рубрике
как подраздел в разделе, поэтому привязка, сделанная НА САМОЙ рубрике, до её
статей доходит. Прочитать результат: `GET /articles/by-slug/{slug}` отвечает
полем `design` (админский `GET /articles/{id}` оформление не резолвит и его не
несёт). Пока у статьи нет своих привязок, результат совпадает с прежним — сайты
ничего не замечают.

⚠️ `body_article` — слот **ЛЕЙАУТА**, а не статьи: какой вид тела достанется
статьям, решает ступень НАД статьёй. `PUT /design/bindings/article/{id}` со
слотом `body_article` отвечает `DESIGN_SLOT_NOT_FOR_RESOURCE`.

### Границы папки лейаута

- лимиты те же, что у виджета: ≤ 500 файлов, HTML ≤ 2 МБ, любой файл ≤ 50 МБ,
  ZIP ≤ 60 МБ. Скелет больше 64 КБ — предупреждение `theme.index_large` (он
  ложится на КАЖДУЮ страницу);
- валидатор тот же, минус правила, которые для обвязки бессмысленны
  (`head.stripped`, `page.noindex`, `img.nosize`, `file.unused` на иконках);
  `window.VZ_THEME_BASE` признаётся корнем файлов наравне с `VZ_ASSET_BASE`;
- нечитаемый скелет не публикуется: `422 RELEASE_INVALID` с кодом `html.parse`
  (сломанный скелет лёг бы сразу на весь сайт);
- служебный документ папки ручками `/html-documents/*` не правится
  (`FailedPrecondition THEME_DOCUMENT_PROTECTED`), в списке документов не виден
  и `/content` не отдаёт;
- не-лейаут, чужой и удалённый id в `{id}` одинаково дают `404 THEME_NOT_FOUND`.

## 2в. Компонент с параметрами — одна вёрстка на многих страницах

Вопрос, который стоит задать ДО того, как писать разметку: **эта вёрстка нужна
больше одного раза?** Если ответ «на пяти страницах, и на каждой свои заголовок и
картинка» — html-виджет тут неверный объект: код придётся скопировать пять раз, а
шестая правка станет шестью правками. Этот случай называется **компонент**.

Компонент — тот же документ-папка и те же релизы (§2а), плюс один файл:
`component.json` рядом с `index.html` со схемой параметров, а в разметке стоят
`{{key}}`. Значения живут ВО ВСТАВКЕ, а не в коде: продавец заполняет их формой,
а новый релиз папки обновляет все вставки разом.

```bash
# папка → документ kind=component → релиз → публикация, со схемой в ответе
node backend-3D/tools/html-transfer/transfer.mjs ./banner --component --name "Баннер акции"
```

```json
{ "type": "text", "payload": { "v": 2, "kind": "component",
  "props": { "component": { "ref": 42, "params": { "title": "Осень", "sale": true } } } } }
```

Что важно знать сразу, остальное — в области `GET /docs/components`:

- подстановка **типизированная и серверная**: `text` экранируется, `html` идёт
  через санитайзер уровня 1, `link`/`image` — по whitelist схем; сырого `{{ }}`
  на витрине не бывает, а ключ не из схемы печатает пустоту;
- схема повторяет Shopify theme section (`params` = `settings`), типов восемь:
  `text`, `html`, `image`, `link`, `color`, `number`, `bool`, `select`.
  `products`/`category` пока НЕ поддерживаются: параметр — это значение, а не
  запрос к каталогу;
- в папке разрешён TypeScript: `app.ts` компилируется в соседний `app.js` на
  публикации релиза, сборщик не нужен. На уровне 1 скрипт всё равно вырежет
  санитайзер — живому коду нужен уровень 3 (`--level 3`);
- схему НЕ читают из `component.json` через шлюз: разобранная приезжает полем
  `manifest` у документа (base64) и у каждого релиза (объектом), рядом с
  `active_release_id` — честной проверкой «компонент вообще можно ставить».

## 3. Картинки: залей и переиспользуй

**Аплоад — всегда 3 шага** (одинаково для HTML и для картинок):

```bash
# 1) заявка
POST /v1/storages/files
{ "section": "html-asset", "file_type": "image", "original_name": "hero.webp" }
# → { "result": { "id": "<file_id>" }, "upload": { "id": "<upload_id>", "url": "<upload_url>" } }
#    ⚠️ upload.url может быть ОТНОСИТЕЛЬНЫМ (/_upload/…) — добавь хост API

# 2) сами байты
PUT <upload_url>            # тело = файл, без Authorization

# 3) подтверждение — по upload.id (НЕ по result.id)
POST /v1/storages/files/{upload_id}/done   { }
```

`file_type` — **категория**, НЕ MIME: `image | video | audio | model | file`
(пришлёшь MIME — сервер нормализует его в категорию сам). К товару/документу
файл цепляется по `result.id`; в `/done` идёт `upload.id`.

Секции файлов: `html-source` — сам HTML страницы, `html-asset` — её картинки
(обе скрыты из общей медиатеки магазина, мусор не копится).

**Картинка УЖЕ лежит в интернете — качай её сервером, одним запросом:**

```bash
POST /v1/storages/files/from-url
{ "source_url": "https://cdn.example.com/hero.jpg",
  "section": "html-asset", "file_type": "image" }
# → { "result": { "id": "<file_id>", "public_path": "…webp" }, "file_size": 148213 }
```

Никаких PUT и `/done`: файл уже сохранён, сконвертирован и нарезан,
`public_path` финальный. Только публичные `http(s)`-адреса — ссылка во
внутреннюю сеть (`localhost`, `127.*`, `10.*`, `192.168.*`, `169.254.*`, `::1`)
даёт `400 URL_NOT_ALLOWED`, и это же правило действует на каждый редирект
(их не больше трёх, таймаут 30 с). Не скачалось или это не картинка →
`400 URL_FETCH_FAILED`; больше 10 МБ → `400 FILE_TOO_LARGE`. Формат берётся из
самих байтов (`jpeg|png|gif|webp|bmp|tiff`), а не из `Content-Type` ответа —
для HTML/3D/архивов остаются те же 3 шага выше.

**Правила картинок:**

1. **URL берётся из ответа API** (`result.url` / `file.url`), не выдумывается.
2. **В `src` — относительный путь** (`/files/sunset/view/content/…`): витрина
   отдаёт файлы со своего домена.
3. **Нарезка** — префикс `/w/{ширина}` или `/w/{ширина}/webp` перед путём:
   `/w/1024/webp/files/sunset/view/content/05/d1/…webp`.
   Лестница предгенерируемых ширин: 32 | 64 | 128 | 320 | 640 | 1024 | 1600 |
   2048 | 2560 — бери ступень ≥ 2× CSS-размера (герой 1600, контент 1024,
   карточки 320/640); для `srcset` — те же. Качество: дефолт по ступеням,
   переопределение `?q=40..95`. SVG не нарезаются — вставляй как есть.
4. **Переиспользование:** уже загруженный файл (в т.ч. фото товара из каталога)
   вставляй по его URL — **повторно не загружай**. Один и тот же `file_id`
   можно зарегистрировать в нескольких документах.
5. **Регистрируй ассеты документа** — это защита от удаления файла (`FILE_IN_USE`):

```bash
PUT /html-documents/{id}
{ "id": 10, "item": { "assets": [ { "file_id": "<uuid>", "path": "img/hero.webp" } ] } }
# assets непустой = replace-set (перечисляй ВСЕ ассеты сразу)
```

`path` здесь — **человекочитаемая метка** для учёта («что это за файл в
документе»), а НЕ алиас: рендер не переписывает по нему `src`. В HTML всегда
ставь реальный URL из ответа API (правило 1). Настоящие относительные пути
(`assets/hero.webp` как в папке) даёт **релиз HTML-проекта** (§2а) — там путь
файла и есть его адрес.

### 3.1. Правила картинок из ХРАНИЛИЩА магазина (html-блок уровня 1 и медиатека)

> К файлам релиза HTML-проекта (`assets/…` в папке, §2а) не относится: они
> отдаются байт-в-байт, `/w/` к ним не применяется — размер готовь сам
> (рекомендация ≤ 400 КБ, `width`/`height` у `<img>`). Картинки товаров в
> своей вёрстке приходят уже нарезанными (`{{ p.preview }}`).

Ты загружаешь ОРИГИНАЛ (он нужен для будущих ширин), но в HTML **никогда не
ставишь ссылку на оригинал**. Всегда `/w/{ширина}/webp{путь}`.

**Правило одной строки:** ширина = ближайшая ступень ≥ 2× того, сколько пикселей
картинка реально занимает на экране.

Лестница предгенерированных ширин: **32 · 64 · 128 · 320 · 640 · 1024 · 1600 · 2048 · 2560**
(другие ширины тоже работают, но считаются на лету — первый посетитель ждёт).

| Что на странице | Занимает | Ставь | С `srcset` |
|---|---|---|---|
| Герой во всю ширину | ~1200 px | `1024` | `1024 1x, 1600 2x` |
| Картинка в колонке текста | ~600-800 px | `640` | `640 1x, 1024 2x` |
| Карточка в сетке 3-4 колонки | ~250-300 px | `320` | `320 1x, 640 2x` |
| Мелкая плитка, логотип, иконка | 40-80 px | `64` | `64 1x, 128 2x` |
| Аватар отзыва | 48 px | `64` | `64 1x, 128 2x` |

**Скелет каждой картинки в лендинге:**

```html
<img src="/w/320/webp/vizen-prod-files/sunset/view/content/ab/cd/файл.webp"
     srcset="/w/320/webp/...файл.webp 1x, /w/640/webp/...файл.webp 2x"
     width="300" height="225" loading="lazy" alt="Кровать Аврора 120×90">
```

- `srcset` с парой 1x/2x — иначе на ретине картинка мыльная, а без неё грузится
  вдвое больше нужного;
- `width`/`height` — чтобы страница не прыгала при загрузке;
- `loading="lazy"` — на всех, **кроме первой картинки экрана** (герой грузим сразу,
  ему `fetchpriority="high"`);
- `alt` — по-человечески, это и SEO, и доступность.

**Фоновая картинка в CSS** (`background-image`) не умеет `srcset` — там просто
бери ступень на один шаг больше слота: блок шириной ~1200 → `/w/1600`.

**Чего делать НЕЛЬЗЯ:**

| Нельзя | Почему |
|---|---|
| `src="https://storage.yandexcloud.net/..."` или `/files/...` | это оригинал, может весить 10 МБ на плитку 300 px |
| одна ширина на все слоты (`/w/1600` везде) | герой и миниатюра — разный вес в 20 раз |
| ширина «с запасом на всякий случай» | каждый лишний шаг лестницы — это ×2-4 к весу |
| `?q=` без надобности | качество уже подобрано по ступеням |
| SVG через `/w/` | вектор растеризуется и теряет смысл — вставляй как есть |

**Самопроверка перед сдачей** (обязательно прогони):

```bash
# 1) в разметке НЕТ прямых ссылок на оригинал — должно быть 0
curl -s https://{slug}.vizen.shop/{страница} | grep -c 'storage.yandexcloud\|"/files/'

# 2) посмотреть, какие ширины реально запрашиваются
curl -s https://{slug}.vizen.shop/{страница} | grep -oE '/w/[0-9]+' | sort | uniq -c

# 3) вес самой тяжёлой картинки страницы (герой) — норма до ~200 КБ
curl -s -o /dev/null -w '%{size_download}\n' https://{slug}.vizen.shop/w/1024/webp/...
```

Если в первой команде не ноль — страница не готова.

## 4. Залей HTML и привяжи к документу (уровень 1/2; для папки уровня 3 шаг не нужен — §2а)

```bash
# 3 шага аплоада с section=html-source, file_type=file (категория, не MIME)
POST /v1/storages/files
{ "section": "html-source", "file_type": "file", "original_name": "index.html" }
PUT <upload_url>                      # тело = твой HTML
POST /v1/storages/files/{upload_id}/done  { }

# привязка файла к документу
PUT /html-documents/{id}
{ "id": 10, "item": { "source_file_id": "<file_id>" } }
```

Правка страницы = новый файл + повторный `PUT` документа (истина — исходник).
Для HTML-проекта с релизами (§2а) этот шаг не нужен: `index.html` — часть
релиза, и правка — новый релиз.

## 5. Сделай блок и настрой его обёртку (html-блок уровня 1 в коробке)

> ⚠️ Перенесённая папка (уровень 3) коробки не имеет, пока не задан
> `props.html.boxed: true`: настройки обёртки к ней не применяются, геометрию
> задаёт твой CSS (`/docs/transfer` §7). Этот раздел — про блок в коробке.

```bash
POST /content-blocks
{ "item": { "name": "Промо весна",
  "sections": [ { "type": "text", "payload": { "v": 2, "kind": "html",
    "props": {
      "blockWidth": "full",
      "contentWidth": "content",
      "marginTop": 24, "marginBottom": 24, "blockRadius": 16,
      "html": { "html_document_id": 10, "level": 1 }
    } } } ] } }
# → { "result": { "id": 136, … } }
```

### Сколько документов на страницу — правило

**Решаешь ты.** Один документ на всю страницу — норма: перенесённая папка
(§2а) — всегда так, правка части = частичный релиз одного файла. Отдельные
документы-секции (герой, преимущества, отзывы, CTA) нужны только когда владелец
хочет переставлять части в админке, прятать одну на мобилке или чередовать твои
секции с готовыми блоками платформы (галереи, карточки товаров, формы); цена —
свой релиз у каждой части.

### Справочник настроек обёртки (ровно то, что в панели админки)

| Панель | Ключ в `props` | Значения |
|---|---|---|
| Ширина блока | `blockWidth` | `"content"` (дефолт, колонка сайта) / `"full"` (до края экрана) |
| Ширина содержимого (виден при `full`) | `contentWidth` | `"content"` (вернуть в колонку) / `"full"` |
| Отступ ↑ / ↓ | `marginTop` / `marginBottom` | число, px |
| Отступ по бокам | `marginX` | число, px (Нет = 0; S/M/L — пресеты панели) |
| Закругление углов | `blockRadius` | число, px. ⚠️ У самой ПОЛОСЫ дефолт 0 — 16 px это системное скругление ВНУТРЕННИХ элементов блока (класс `.vz-radius`). Актуальные значения — `GET /docs/widgets`, раздел `wrapper` |
| Видимость на устройствах | `visibility` | `{ "min": 320, "max": 1024 }` — ширина окна, px |
| Слой планшета (640–1023) | `tablet` | те же ключи по-полевому |
| Слой мобилки (< 640) | `mobile` | те же ключи по-полевому |

Готовые комбинации:

```jsonc
// герой во всю ширину, текст в колонке сайта
{ "blockWidth": "full", "contentWidth": "content" }

// узкая вставка с воздухом и радиусом
{ "blockWidth": "content", "marginX": 24, "blockRadius": 16,
  "marginTop": 32, "marginBottom": 32 }

// на мобилке убрать боковые отступы и радиус
{ "blockWidth": "full", "marginX": 24,
  "mobile": { "marginX": 0, "blockRadius": 0 } }

// блок только для десктопа
{ "blockWidth": "full", "visibility": { "min": 1024 } }
```

**Правило вёрстки блока в коробке (уровень 1 или `boxed`):** шириной управляет
обёртка, не твой HTML — корень `width: 100%`, без `100vw` и отрицательных
margin (они дают горизонтальный скролл), а `position: fixed` внутри коробки
считается от контейнера страницы, не от окна. У перенесённой папки уровня 3
без коробки containment снят — `fixed`, `100vw`, `100dvh` работают как в
обычном сайте (§2а «Каскад витрины и рантайм»).

### Готовый блок платформы: «Товары категории» (`kind: "productListing"`)

Листинг каталога НЕ верстай сам — поставь готовый блок платформы: сетка
товаров категории с фильтром по её характеристикам и пагинацией, тот же
компонент, что на странице категории. Данные в payload не хранятся — витрина
сама берёт опубликованные товары по `categoryId`.

```jsonc
POST /content-blocks
{ "item": { "name": "Каталог: диваны",
  "sections": [ { "type": "text", "payload": { "v": 2, "kind": "productListing",
    "props": { "listing": { "categoryId": 12, "showFilter": true, "limit": 24 },
               "blockWidth": "content" } } } ] } }
```

`props.listing`: `categoryId` (id категории `type='product'`; обязателен —
без него блок не рендерится) · `showFilter` (дефолт true) · `limit` (4–48,
дефолт 24) · `template` (oneOf `["default"]`; пусто = дефолт магазина;
незнакомое значение читается как `default`). Схема аддитивная, частичный
объект допустим. Фильтр/пагинация НЕЗАВИСИМЫ у каждого блока: свой неймспейс
URL-параметров `b<blockId>_f_*` / `b<blockId>_page`; родной листинг страницы
категории живёт на чистых `?f_*`/`?page`.

### Готовый блок платформы: «Карточка товара» (`kind: "productCard"`)

Карточку товара тоже не верстай сам: `props.card.productId` — и витрина
рисует полноценную карточку (шаблон Простой/Классический/3D живёт В ТОВАРЕ,
`card_template`, в блоке не дублируется).

```jsonc
{ "type": "text", "payload": { "v": 2, "kind": "productCard",
  "origin": "builder", "props": { "card": { "productId": 5 } } } }
```

### Комплектные блоки — создаются сами

Категория `type='product'` при создании УЖЕ получает блок «Товары категории»
+ манифест `__page:top`; товар — блок «Карточка товара». После
`POST /categories` / `POST /products` НЕ создавай эти блоки вручную — сначала
прочитай `GET /categories/{id}` (в ответе `content_blocks`) и работай с
готовыми.

Комплектная секция помечена РОЛЬЮ: `payload.role: "bundled"` («технический»
блок). Правила роли: НЕ удаляй такой блок (редактор его и не даёт удалить, а
миграционная доводка вернёт) — чтобы спрятать со страницы, подними флаг в
манифесте: `__page:top` → `payload.page.hideSystemBlock: true` (галочка
«Скрыть технические блоки» в настройках страницы; обратимо). Свои блоки роль
не ставят — она зарезервирована за платформой.

### Привязки оформления (Ш-1): шаблон вместо копии

Ресурс может РИСОВАТЬСЯ блоком библиотеки без собственной копии — привязкой:

```bash
# назначить товарам раздела свою группу тела (наследуется вниз по дереву)
PUT /design/bindings/category_children/{categoryId}
{ "slots": { "body": <groupId> } }

# что фактически применится к ресурсу (с «унаследовано от…»)
GET /design/bindings/product/{id}
# → { own: [...], resolved: { body: {block_id, source: "category:10"} } }

# охват блока перед правкой/удалением («затронет N страниц»)
GET /content-blocks/{id}/usage   # → { total, refs[] } (refs: привязки + манифесты resource_type "layout"/"zone")
```

Типы ресурсов: `shop` (id `0`) · `category` (сама страница) · `category_children`
(товары внутри) · `product` · `article` · `theme` (id = группа-ЛЕЙАУТ, §2б) ·
`system` (системная страница витрины по номеру из реестра, §2б).
Слоты действующей модели — `header` · `feed` · `body` · `footer` · `theme`; у
ресурса `theme` дополнительно тела по видам `body_product` · `body_category` ·
`body_news` · `body_article` (они слоты ЛЕЙАУТА, не страницы), у `system` только
`theme`/`header`/`footer`. У слота ТРИ ответа: нет в карте = наследовать, `0` =
«здесь ничего» (лестница останавливается — так гасят шапку), id = эта группа.
Легаси-пара `layout`/`chrome` (блок-манифест `kind=chromeKit`) продолжает
работать в старых магазинах, но для нового кода не используется — полная модель
в области `GET /docs/chrome` §3.1–3.6. Разрешение цепочки приезжает полем
`design` в `GET /categories/{id}` и публичном by-slug (у статьи — в
`GET /articles/by-slug/{slug}`). Мутация шлёт вебхук
**`design.changed`** `{company_id, resource_type, resource_id, slots}` — только
из прод-контура (дев-правки доезжают публикацией).

### Картинки в HTML: ничего не оптимизируй руками

Ставь обычный `<img src="…">` со ссылкой на свой файл — **нарезку картинок
делает сервер** при отдаче документа: подставит `/w/{width}` + `srcset`
640/1024/1600 + `sizes="100vw"` + `loading="lazy"`. Оригинал в разметку не
попадёт, даже если ты дал ссылку на PNG в несколько мегабайт.

Что сервер НЕ трогает (и почему):
* ссылки на ЧУЖИЕ домены — их не обслуживает наш нарезчик;
* `svg` и `data:` — вектор не растеризуем, инлайн не переписываем;
* разметку, где ты САМ задал `srcset` — считаем, что адаптив продуман.

Хочешь другую ширину — задавай размер слота стилями; ступень подберёт браузер.

## 6. Собери страницу и привяжи блоки

```bash
# слаг занят? проверь ЗАРАНЕЕ — иначе 409 AlreadyExists SLUG_TAKEN
GET /categories/by-slug/promo-vesna?company_id={id}     # 404 = слаг свободен

# страница = категория type='page'
POST /categories
{ "item": { "name": "Промо весна", "type": "page", "is_published": true,
            "seo": { "slug": "promo-vesna" } } }
# → { "result": { "id": 42, … } }   ← у категорий id ЧИСЛО (см. §0)

# ОГЛАВЛЕНИЕ ЗОНЫ (обязательно!) — служебный блок с именем __page:top,
# он говорит редактору, в каком порядке показывать блоки страницы
POST /content-blocks
{ "item": { "name": "__page:top", "sections": [ { "type": "text", "payload": {
    "v": 2, "kind": "zone", "refs": [136, 137] } } ] } }
# → { "result": { "id": 140 } }   (есть и __page:bottom — нижняя зона)

# привязка — REPLACE-SET: перечисляй ВСЕ блоки страницы разом,
# ВКЛЮЧАЯ оглавление (иначе ссылки в нём «битые»)
PUT /categories/42/content-blocks
{ "id": 42, "items": [ { "block_id": 140, "sort_order": 1 },
                       { "block_id": 136, "sort_order": 2 },
                       { "block_id": 137, "sort_order": 3 } ] }
```

⚠️ `PUT /content-blocks/{id}` заменяет `item` ЦЕЛИКОМ (имя + все секции) —
правя один проп, отправляй блок полностью, иначе затрёшь остальное.

Аналогично: `PUT /products/{id}/content-blocks`, `PUT /articles/{id}/content-blocks`.
Один блок можно привязать к нескольким страницам.

**Почему оглавление обязательно.** Витрина отрисует страницу и без него, но в
редакторе админки владелец увидит блоки только как «прочие» — их нельзя будет
двигать между зонами. С оглавлением всё, что ты создал, сразу редактируется
человеком как обычные блоки.

Правила оглавления:
- максимум ОДИН `__page:top` и один `__page:bottom` на страницу;
- в `refs` — id блоков в порядке отрисовки (числа);
- флаги страницы живут ТОЛЬКО в `__page:top`, рядом с `refs`:
  `"page": { "hideSiteHeader": true, "hideSiteFooter": true, "hideSystemBlock": true }`.
  Каждый флаг гасит СЛОЙ целиком, а не отдельный блок: `hideSystemBlock`
  убирает весь технический слой — карточку, ленту, крошки, заголовок страницы.
  Отдельного `hideBreadcrumbs` больше нет (удалён 2026-08-20): он гасил только
  легаси-вариант крошек и не трогал виджет, то есть был вторым механизмом для
  того, что уже делает слой. Если он встречается в ваших заметках — флаг мёртв,
  сервер его сохранит и ничего не изменит;
- редактируя существующую страницу: прочитай её целиком
  `GET /categories/by-slug/{slug}?company_id={id}` (отдельной ручки
  «получить блок по id» НЕТ), найди в `content_blocks[]` блок с именем
  `__page:top`, возьми его `refs`, допиши свои id, отправь оглавление через
  `PUT /content-blocks/{id}` и повтори привязку replace-set'ом;
- ⚠️ НЕ ставь блокам `"origin": "builder"` — это метка блоков, созданных
  редактором; с ней твои блоки исчезнут со страницы при первом же сохранении.

## 7. Макет сайта и четыре режима вёрстки html-блока уровня 1

> ⚠️ Раздел про html-блок в коробке платформы. Для перенесённой папки (уровень
> 3, без коробки) главный документ — `GET /docs/transfer`: здесь для неё нет
> ни одного правила.

Твой HTML — **часть страницы, а не iframe**: он живёт внутри общего контейнера
сайта, наследует его шрифт и цвет, соседствует с шапкой, меню и подвалом.
Поэтому верстать надо ПОД этот контейнер.

### Числа макета — бери из API, не из головы

`GET /v1/storefronts/resolve?slug={магазин}` → поле `layout` (публично):

```jsonc
"layout": {
  "content_max_px": 1600,              // макс. ширина контента сайта
  "container_padding_mobile_px": 16,   // боковые отступы контейнера
  "container_padding_tablet_px": 24,   // (< 640 / 640-1023 / >= 1024)
  "container_padding_desktop_px": 32,
  "breakpoint_tablet_from_px": 640,    // границы слоёв адаптива
  "breakpoint_desktop_from_px": 1024,
  "content_box_max_px": 1536,          // ПОЛЕЗНАЯ коробка = 1600 - 2*32
  "margin_x_presets_px": [0,16,32,64], // пресеты панели «Отступ по бокам»
  "radius_presets_px": [0,8,16,32],    // пресеты «Закругление углов»
  "radius_default_px": 16,
  "font_family": "ui-sans-serif, system-ui, sans-serif, …",
  "base_font_size_px": 16
}
```

### Режим определяется ДВУМЯ настройками блока

### Семь правил html-блока уровня 1 в коробке (нарушение = сломанный блок)

1. **Блок в коробке не управляет своей внешней геометрией.** Ширина,
   вертикальные зазоры, боковой воздух, радиус — это НАСТРОЙКИ блока (`props`),
   а не твой CSS. `100vw` и отрицательные margin дают горизонтальный скролл, а
   `position:fixed` в коробке считается от контейнера страницы, не от окна
   (в папке уровня 3 без коробки — от окна, §2а).
2. **Текст никогда не касается края экрана.** Полноширинный блок обязан дать
   содержимому боковой отступ: класс `.vz-inner` (колонка сайта) или `.vz-pad`
   (полная ширина + отступ), либо свой `padding-inline` не меньше
   `var(--vz-gutter)`. Исключение — только медиа «край в край» (фото, карта,
   слайдер), но не текст на них без собственных отступов.
3. **Ничего не выходит за границы блока.** Горизонтального скролла на странице
   быть не должно ни на одной ширине — проверь 360 / 768 / 1920.
4. **Блок самодостаточен.** Он не знает о соседях, не рассчитывает на их
   отступы и не «залезает» в них. Стыковка блоков — забота платформы.
5. **Секции — по желанию владельца.** Дели на блоки, если он хочет
   переставлять части в админке, скрывать одну на мобилке или переиспользовать
   «герой» на другой странице; цельная страница одним документом — норма.
6. **Стиль твой, сетка общая.** Внутри блока верстай как хочешь, но опорные
   величины (ширина колонки, боковой отступ, брейкпоинты) бери из
   `layout` — не выдумывай свои.
7. **Адаптив обязателен.** Минимальная поддерживаемая ширина — 360 px; сетки
   схлопывай медиазапросами по границам `breakpoint_tablet_from_px` и
   `breakpoint_desktop_from_px`.

**Ключевой инструмент — класс `.vz-inner` и переменная `--vz-content-box`.**
Платформа отдаёт внутрь твоего документа два готовых класса и две переменные.
Оговорка: они доступны на ОПУБЛИКОВАННОЙ витрине (уровень 1 рендерится Shadow
DOM-ом прямо в странице); предпросмотр в редакторе и уровень 2 «Со скриптом»
показываются изолированным iframe — там этих классов/переменных НЕТ, поэтому
критичную геометрию дублируй своим CSS-фолбэком (например, свой `padding-inline`).

| Инструмент | Что делает |
|---|---|
| `.vz-inner` | держит слой в колонке сайта: `max-width: var(--vz-content-box); margin-inline: auto` |
| `.vz-pad` | полная ширина + боковой отступ страницы: `padding-inline: var(--vz-gutter)` |
| `--vz-content-box` | ширина колонки сайта. ≈1536 px — только у полноширинного блока на широком экране; в остальных контекстах величина ОТНОСИТЕЛЬНАЯ (коробка блока минус отступы) — не завязывайся на число, используй переменную |
| `--vz-gutter` | боковой отступ страницы: 16 / 24 / 32 px по ступеням |

Твой корневой элемент всегда во всю ширину блока — фон рисуй на нём, а текст и
сетку оборачивай в `.vz-inner` (по сетке сайта) или `.vz-pad` (во всю ширину, но
с отступом от краёв). Переключатель «Содержимое внутри» в панели (доступен ТОЛЬКО
у блока «во всю ширину», `blockWidth:"full"`) меняет значение `--vz-content-box`:
`content` → колонка сайта, `full` → 100% (ограничение снято).

| # | Настройки блока | Как выглядит | Как верстать ТЕБЕ |
|---|---|---|---|
| **A** | `blockWidth:"content"` (дефолт) | блок уже в колонке сайта | верстай на `width:100%`, `max-width` не нужен. Для длинного текста задай читаемую меру строки: `.text{max-width:66ch}` — колонка 1536 px даёт ~200 символов, это нечитаемо |
| **B** | `blockWidth:"full"` + `contentWidth:"content"` | фон до краёв экрана, контент по сетке сайта | фон/градиент — на корневой элемент; текст и карточки — внутрь `<div class="vz-inner">`. Самый частый режим для героев |
| **C** | `blockWidth:"full"` + `contentWidth:"full"` | во всю ширину, ограничений нет | ограничивай сам: `.wrap{max-width:1536px;margin-inline:auto;padding:0 32px}` (число бери из `layout.content_box_max_px`) |
| **D** | `blockWidth:"full"`, `contentWidth:"full"`, `marginX:0`, `blockRadius:0` | край в край, без отступов и скруглений | `width:100%` без внутренних ограничений — для карт, слайдеров, полноэкранных фото. ⚠️ Радиус обязательно 0: иначе углы срежутся прямо у края экрана |

Скелет героя (режим B) — запомни этот паттерн:

```html
<section class="hero">                    <!-- фон во всю ширину -->
  <div class="vz-inner hero__body">       <!-- контент в колонке сайта -->
    <h1>Заголовок</h1><p>Текст</p>
  </div>
</section>
<style>
  .hero { background: linear-gradient(120deg,#101828,#1d2939); color:#fff; }
  .hero__body { padding: 64px 32px; }     /* свои внутренние отступы */
</style>
```

**Как выбрать:** текстовая секция, карточки, форма → **A**. Герой с фоном на всю
ширину и текстом по сетке сайта → **B**. Своя нестандартная широкая сетка →
**C**. Полноэкранная картинка/карта/слайдер → **D**.

JSON секции для каждого режима:

```jsonc
// A — по контенту
"props": { "blockWidth": "content" }
// B — фон во всю ширину, контент в колонке сайта  ← рекомендуемый для героев
"props": { "blockWidth": "full", "contentWidth": "content" }
// C — во всю ширину, ограничиваешь сам
"props": { "blockWidth": "full", "contentWidth": "full" }
// D — край в край, без отступов
"props": { "blockWidth": "full", "contentWidth": "full", "marginX": 0, "blockRadius": 0 }
```

### Что обёртка делает с содержимым блока уровня 1 (важно)

- **Режет не полоса, а хост твоей вёрстки.** Сама полоса блока (`div.vz-box`)
  не обрезает ничего: тени, свечения и наложения выходят за неё целиком. А вот
  хост Shadow DOM html-блока обрезает ПО УМОЛЧАНИЮ — под скругление, с запасом
  2 px, чтобы не срезать боковой вынос букв. Поэтому выпадашка и модалка из
  твоей вёрстки обрежутся по краю блока, пока не задан
  `props.overflow: "visible"` (§17.0). Текст вплотную к границе всё равно не
  прижимай — дай ему свой внутренний отступ (`.vz-pad`, `.vz-inner` или
  собственный `padding`).
- **Вертикальные зазоры между блоками НЕ складываются.** Шов между соседями
  один: `max(marginBottom верхнего, marginTop нижнего)`; если ЛЮБОЙ из двух
  задан явным `0` — блоки встык (ноль побеждает всё); оба пусты — системные
  24 px. Рисуется шов один раз, как margin-top нижнего блока; `marginBottom`
  применяется «как есть» только у последнего блока ленты.
- **Радиус в режиме «край в край» обязателен нулевой** (`blockRadius: 0`),
  иначе скругление будет видно прямо у кромки экрана.

### Отступы и радиус — тоже настройки блока, а не CSS

Вертикальные зазоры (`marginTop`/`marginBottom`), боковой воздух (`marginX`) и
закругление (`blockRadius`) задавай **пропами блока**, а не в своём CSS: тогда
владелец сможет поправить их в панели, и блок останется согласован с соседями.
Значения бери из `layout.margin_x_presets_px` / `radius_presets_px`.

### Типографика и цвет

Шрифт и цвет текста **наследуются** от страницы (`layout.font_family`,
`base_font_size_px`). Хочешь фирменный вид блока — задавай `font-family` явно;
хочешь слиться со страницей — **не трогай** `font-family` вообще. Заголовки
масштабируй от 16 px базы (`clamp()` удобен), не завязывайся на vw.

### Чего не делать в блоке уровня 1

`100vw` и отрицательные margin (вылет уже даёт `blockWidth:"full"`),
горизонтальный скролл страницы и внутри блока. `position:fixed` здесь считается
от контейнера, а стили на `html`/`body` до страницы не доходят (Shadow DOM) —
и то и другое работает только в папке уровня 3 (§2а «Каскад витрины и
рантайм»). Обрезка — не страховка: полоса блока не
режет ничего, а обрезает только хост твоей вёрстки, и его обрезку автор может
выключить (`props.overflow: "visible"`).

## 8а. Правила вёрстки html-блока уровня 1 (Shadow DOM, санитайз)

> К папке уровня 3 не относится: там настоящий DOM, скрипты исполняются, якоря
> работают, `html/body/:root` переписываются на обёртку проекта (§2а).

1. **Что вырежет сервер (уровень 1):** `<script>`, атрибуты `on*`
   (`onclick`, `onerror`…), `javascript:`-ссылки, `<iframe>`, `<form>`/`<input>`.
   **Что уцелеет:** `<style>`, инлайн-стили, классы, `id`, семантика
   (`section/article/header/footer/figure/picture/source`), `srcset`,
   data-URI картинки, `<link rel="stylesheet" href="https://…">` (только https).
2. **Изоляция (уровень 1):** твой HTML рендерится в Shadow DOM — твои стили не
   выходят наружу, и стили СОСЕДНИХ блоков не заходят внутрь. Поэтому: не
   стилизуй `html`/`body`, держи один корневой контейнер, префиксуй классы
   (например `vzp-`). На уровне 3 наоборот: общий каскад, на вёрстку ложится
   preflight витрины, `html/body/:root` → `.vz-body-{doc}`.
   **Исключение одно, и оно в твою пользу (с 2026-09-13): CSS ЛЕЙАУТА внутрь
   тени доезжает.** Витрина кладёт в тень те же таблицы стилей, что стоят в
   `<head>` страницы (`<link rel=stylesheet>` и инлайновые `<style>` скелета), и
   оборачивает твою разметку в `<div class="vz-theme-{doc}">` — так что шрифт,
   переменные и правила `body {}` / `:root {}` из папки лейаута применяются, а
   твои собственные правила всё равно перебивают их (стили темы идут ПЕРЕД
   разметкой). Скрипты лейаута в тень не едут (она рисуется и в запретных
   зонах), иконки и `preload` — тоже. Не путай два разных «CSS»: **свой** файл
   документа на уровне 1 не подключится (`<link>` с относительным путём
   санитайзер вырезает — см. правило 1, клади в инлайновый `<style>` или бери
   уровень 3), а **CSS лейаута** приезжает сам.
3. **Наследование:** шрифт, цвет текста и CSS-переменные темы проникают внутрь —
   не сбрасывай их без нужды (`all: initial` только если нужен независимый вид).
4. **Ширина** — см. §5, правило вёрстки.
5. **Адаптив** — медиазапросы и `max-width: 100%` для картинок; страница обязана
   жить на 360 px.
6. **Якоря (уровень 1):** `#anchor` внутри блока не прокручивает страницу
   (граница Shadow DOM) — не строй навигацию на якорях внутри одного документа.
   На уровне 3 якоря работают.
7. **Размер:** держи документ до ~256 КБ; жёсткий предел — 2 МБ (больше сервер
   не отдаст, и блок деградирует в iframe без SEO).
8. **Сохраняй чужие ключи:** редактируя существующий блок, не выбрасывай
   незнакомые поля `payload` — они могут принадлежать другим возможностям.

## 8. Квизы и интерактив (уровень 2)

Нужен JS — создай документ с `"level": 2`. Он рендерится в iframe-песочнице
(`sandbox="allow-scripts"`, без доступа к сайту и cookie), скрипты работают,
**но контент не попадает в поисковую выдачу**. Рецепт: интерактив уровнем 2,
а SEO-текст — отдельным блоком уровня 1 на той же странице.

## 9. Проверь результат

```bash
# 1) страница отдаёт твой текст в СЫРОМ html (без JS) — это и есть SEO
curl https://{магазин}/promo-vesna | grep "Заголовок из моего HTML"

# 2) санитизированный контент документа (публично)
curl "https://api.vizen.shop/html-documents/10/content?company_id={id}"
# → { "html": "…", "level": 1, "updated_at": "…" }

# 3) где используется документ
curl "https://api.vizen.shop/html-documents/10/usage" -H "Authorization: Bearer …"
```

Если `…/content` отвечает **404** — документ ещё не привязан ни к одному блоку
(так и задумано: неприкреплённые черновики публично не читаются), либо у него
`level: 2`, либо не залит файл.

## 10. Ошибки и лимиты

Тело ошибки всегда одно: `{ "error": "rpc error: code = <КОД> desc = <МАРКЕР>" }`.

| Ситуация | HTTP | Маркер в `desc` |
|---|---|---|
| Нет скоупа на ПРИВАТНОЙ ручке | 403 | `PAT_SCOPE_MISSING (required_scope: …)` |
| Ручка вообще не открыта для токенов | 403 | `PAT_METHOD_NOT_ALLOWED` |
| Нет `catalog:read`, ручка ПУБЛИЧНАЯ | 200 | ответа об ошибке НЕТ: приходит проекция анонима (черновиков и скрытого нет). На своей неопубликованной странице — `404 …_NOT_FOUND`; без `company_id` — `403 AUTH_COMPANY_PROBLEM` (`/products`) или `403 COMPANY_ID_PROBLEM` (`/categories`, `/articles`). Поэтому **всегда передавай `company_id`** |
| Чужой/несуществующий/скрытый объект | 404 | `…_NOT_FOUND` (общая маска — существование не палится) |
| Документ используется блоком, а его удаляют | 400 | `DOCUMENT_IN_USE: used by N content blocks` |
| Файл используется документом, а его удаляют | 400 | `FILE_IN_USE` |
| Слаг страницы/категории занят | 409 | `AlreadyExists … SLUG_TAKEN` (проверяй `by-slug` заранее) |
| `page.number` не передан в `/articles` | `400` (нумерация с 1; у `/products` необязателен) |
| Картинка больше 10 МБ | 400 | `FILE_TOO_LARGE` на `/done` (сожми перед загрузкой) |
| Слишком много запросов | 429 | `RATE_LIMITED` + заголовок `Retry-After` (секунды). Лимиты на компанию в минуту: чтение 600, запись 240, storage-мутации 180 — жди `Retry-After` и повторяй |
| Данные блока (`sections`) | ≤ 64 КБ — HTML внутрь НЕ вкладывать, только `html_document_id` |
| Размер html-документа | рекомендованно ≤ 256 КБ, максимум 2 МБ |

## 11. Чек-лист «страница готова»

### 11.1. Готовый сайт / папка (уровень 3)

- [ ] Ключ дев-контура на время итераций; прод — только финальная публикация.
- [ ] Все пути относительные, внутри папки; внешние ресурсы только `https://`.
- [ ] Свой base.css под корневым классом: заголовки, списки, ссылки, кнопки не
      зависят от браузерных дефолтов (preflight витрины их сбросит).
- [ ] Скрипт стартует сразу и переинициализируется по `vz:navigate`.
- [ ] Данные магазина перепривязаны (`vz-for`, `{{ product.* }}`, `vz-add-to-cart`,
      `href="form:<id>"`); ссылки ведут на реальные слаги.
- [ ] Открыл по прямому адресу И переходом по ссылке внутри сайта; скриншоты
      1440 и 390; консоль без ошибок и без 404 по `/_html/`; `/cart` показывает
      шапку.
- [ ] `--doc <id> --list` показывает активный релиз; знаю команду отката.
- [ ] `curl` страницы показывает мой текст в сыром HTML.

### 11.2. Html-блок уровня 1 внутри страницы из виджетов

- [ ] Прочитал магазин и каталог; ссылки ведут на реальные слаги.
- [ ] Картинки залиты один раз, вставлены через `/w/{ширина}/webp`, ассеты
      зарегистрированы в документе.
- [ ] В HTML нет `100vw` и отрицательных margin; ширина задана `props` блока,
      а не CSS документа.
- [ ] Страница читается на 360 px.
- [ ] `curl` страницы показывает мой текст в сыром HTML.
- [ ] Блок привязан, страница `is_published: true`.

## 12. Как отчитываться перед владельцем (ОБЯЗАТЕЛЬНО)

Ты работаешь не в вакууме: результат принимает человек. Половина «багов»,
которые он видит, — это на самом деле непонятная сдача работы. Правила:

### 12.1. Дев-ключ НЕ публикует — и это нормально

> Исключение: владелец может выдать ключу право **`publish:write`** (галочка
> «Разрешить публиковать черновик» при выпуске). Тогда тебе доступен
> `POST /v1/dev/publish` — но жми его ТОЛЬКО когда владелец прямо попросил
> «опубликуй», а не по своей инициативе.

Ключ в контуре «черновик» создаёт объекты в черновой версии сайта. На живом
сайте их НЕТ, пока владелец не нажмёт «Опубликовать». Значит:

- **404 на живом сайте сразу после твоей работы — ожидаемый результат**, а не
  твоя ошибка. Не пытайся «чинить» его повторными записями.
- Самопроверка: `GET /categories/by-slug/{slug}?company_id={id}` **с токеном**
  → `200` (черновик на месте), **без токена** → `404` (ещё не опубликовано).
  Именно такая пара ответов = работа сделана правильно.
- Черновая витрина по прямой ссылке НЕ открывается даже владельцу: она
  доступна только из админки (сессия владельца), чтобы черновик не попал к
  посетителям и поисковикам. Не давай ссылок вида `slug--dev.vizen.shop` —
  они не сработают.

### 12.2. Ссылки в отчёте — только полные и кликабельные

Плохо (владелец не может открыть, ссылка мёртвая):

```
Готово, страница /dvukhyarusnye-pod-zakaz — смотри в админке
```

Хорошо:

```
Готово: страница «Двухъярусные кровати под заказ»

Публичный адрес (заработает после публикации):
https://{slug}.vizen.shop/dvukhyarusnye-pod-zakaz

Сейчас в черновике. Чтобы увидеть и опубликовать:
1. Откройте админку магазина
2. Переключитесь в режим ДЕВ (оранжевый переключатель)
3. Нажмите «Опубликовать»
```

Правила ссылок:
- **всегда абсолютный URL со схемой** `https://…` — относительный путь
  `/promo` в чате не кликается;
- домен витрины бери из `GET /v1/orgs/current` (`slug` → `https://{slug}.vizen.shop`)
  или из кастом-домена магазина, НЕ выдумывай;
- ссылка на товар/категорию — по её реальному слагу из ответа API, не по id,
  если слаг есть;
- если объект в черновике — рядом со ссылкой пиши «заработает после публикации».

### 12.3. Что обязательно в финальном отчёте

1. **Что создано** — списком, человеческими названиями (не id).
2. **Куда смотреть** — полные URL + пометка про черновик.
3. **Что нажать владельцу** — конкретные шаги до публикации.
4. **Что НЕ получилось** — честно, с причиной. Если ручка ответила
   `PAT_METHOD_NOT_ALLOWED` (например, настройки витрины, логотип, валюта —
   они закрыты для токенов) — так и напиши: «через API недоступно, сделайте
   в админке вот здесь». Не молчи и не выдавай частичный результат за полный.
5. **Что осталось на потом** — если работа делится на этапы.

### 12.5. Прод или черновик — определи ДО начала работы

Первым делом спроси у API, куда пишет твой ключ:

```bash
curl https://api.vizen.shop/v1/account/token -H "Authorization: Bearer $TOKEN"
```

Дальше веди себя по-разному — и **обязательно скажи владельцу в первом же
сообщении**, в каком режиме работаешь:

| Ключ | Что говоришь владельцу | Что можно |
|---|---|---|
| Черновик | «Работаю в черновике — на живом сайте изменений не будет, пока вы не опубликуете» | создавать/править что угодно; публиковать — нельзя (или только по прямой просьбе, если выдан `publish:write`) |
| Полный доступ | ⚠️ «Внимание: правки уходят СРАЗУ на живой сайт, его видят покупатели» | всё; перед массовыми правками спроси подтверждение |

Правило безопасности: **при полном доступе не делай массовых операций молча.**
Удалить 50 товаров или перезалить каталог — сначала спроси, потом делай.

### 12.6. Как собирать ссылки (главный источник путаницы)

Адрес магазина НЕ выдумывается — он берётся из API:

```bash
curl https://api.vizen.shop/v1/orgs/current -H "Authorization: Bearer $TOKEN"
# → { "slug": "myshop", ... }  → витрина: https://myshop.vizen.shop
```

Если у магазина подключён свой домен, витрина живёт на нём — тогда ссылки давай
на него, а не на `*.vizen.shop`.

| Что | Как строить | Пример |
|---|---|---|
| Главная | `https://{slug}.vizen.shop/` | `https://myshop.vizen.shop/` |
| Страница/раздел | `https://{slug}.vizen.shop/{seo.slug}` | `https://myshop.vizen.shop/promo-vesna` |
| Товар | `https://{slug}.vizen.shop/{раздел}/{seo.slug}` | `https://myshop.vizen.shop/krovati/avrora-120` |
| Черновик | **ссылку НЕ давать** | открывается только из админки |

Проверь себя перед отправкой ответа:
- ссылка начинается с `https://` (относительный путь `/promo` в чате не кликается);
- в ней настоящий слаг из ответа API, а не id и не выдуманное имя;
- если объект ещё не опубликован — рядом стоит «заработает после публикации».

### 12.7. Шаблон финального сообщения

```
Готово. Работал в ЧЕРНОВИКЕ — на живом сайте пока ничего не изменилось.

Что сделал:
• Страница «Промо весна» — 4 секции, 12 товаров из вашего каталога
• Загрузил 9 фото (взял из карточек товаров, новых не плодил)

Где будет доступно после публикации:
https://myshop.vizen.shop/promo-vesna

Чтобы опубликовать:
1. Откройте админку магазина
2. Переключитесь в режим ДЕВ (оранжевый переключатель)
3. Нажмите «Опубликовать»

Не получилось: не смог поставить логотип — настройки витрины закрыты для
API-ключей (ошибка PAT_METHOD_NOT_ALLOWED). Поставьте в админке:
Компания → Витрина → Логотип. Файл уже загружен в медиатеку.
```

Плохой ответ выглядит так — **не делай так**:

```
Готово, всё создал. Смотри /promo-vesna в админке.
```

(не видно, что создано; ссылка не кликается; не сказано про черновик и
публикацию; умолчал про то, что не сработало)

### 12.4. По ходу работы

- Пиши, что делаешь, короткими шагами: «читаю каталог → 32 товара»,
  «заливаю 9 фото», «собираю страницу». Молчание в чате на 10 минут = владелец
  думает, что всё зависло.
- Наткнулся на ошибку API — **покажи её текст**, а не «что-то не работает».
  Код ошибки (`SLUG_TAKEN`, `FILE_TOO_LARGE`) владелец может передать
  разработчику платформы.
- Не изобретай данные: цены, названия и фото бери из каталога магазина.
  Выдуманный товар на витрине — хуже, чем пустая секция.

## 13. ФОРМЫ: собрать лид-форму и читать заявки

Лид-форма — не вёрстка, а **сущность магазина** (`forms`) плюс готовый блок
платформы. Своими руками `<form>` не верстай: на уровне 1 сервер вырежет и
`<form>`, и `<input>` (§8а), а на уровне 2 форма попадёт в iframe без SEO и без
приёма заявок. Правильный путь — создать форму по API и поставить на страницу
блок `kind:"form"`, который на неё ссылается.

- **Форма живёт отдельно от блока.** Поля, тексты, оформление, баннер, кнопка,
  согласие — всё в `forms.schema`. Блок несёт ТОЛЬКО `props.form.formId`.
- Одна форма может стоять на нескольких страницах; правка формы меняет все её
  вставки разом. «Хочу здесь другую» = вторая форма, а не другой блок.
- Заявки (`form_leads`) — персональные данные покупателей. Они живут ВНЕ
  дев/прод-контура (как заказы): публикация черновика их не касается.

### 13.1. Скоупы и префиксы

- **Скоупы:** `forms:read` (список/чтение форм) · `forms:write` (создать/
  править/удалить) · `leads:read` (читать и выгружать заявки) ·
  `leads:write` (менять статус, удалять заявку).
- ⚠️ **Заявки НЕ открываются под `catalog:*` и `forms:*`.** Токен «на каталог»
  и токен «на конструктор форм» телефонов людей не увидят — это сделано
  намеренно. Нет `leads:read` → `403 PAT_SCOPE_MISSING (required_scope: leads:read)`.
- **Префикс БЕЗ `/v1`:** `/forms`, `/form-leads` (как `/products`,
  `/content-blocks`).
- Тенант всегда из токена: `company_id` в теле и параметрах этих ручек НЕТ.
  Чужая/несуществующая форма — единая 404-маска.

⚠️ **`schema`, `values`, `utm`, `snapshot`, `csv` — proto-поля `bytes`.** На
JSON-проводе это **base64 сырого JSON** (или base64 файла у `csv`), а не
вложенный объект. Причина — конвенция №8: `google.protobuf.Struct` испортил бы
целочисленные `width`/`gap` схемы. Забудешь закодировать — получишь
`InvalidArgument` от валидатора транспорта, а не «форму без полей».

⚠️ `id` формы и заявки — int64, в JSON приходят **строкой** (`"7"`); `fields_count`,
`new_leads`, `total`, `rows` — обычные числа (§0).

### 13.2. Ручки

| Что нужно | Запрос |
|---|---|
| Список форм компании (+счётчики) | `GET /forms?page.number=1&page.limit=50` → `forms:read` |
| Форма целиком (схема как есть) | `GET /forms/{id}` → `forms:read` |
| Создать форму | `POST /forms` `{"item":{…}}` → `forms:write` |
| Поправить форму (partial) | `PUT /forms/{id}` `{"item":{…}}` → `forms:write` |
| Удалить форму (мягко, 30 дней) | `DELETE /forms/{id}` `{}` → `forms:write` |
| Публичная схема для витрины | `GET /forms/{id}/public` — **аноним** |
| Отправить заявку | `POST /form-leads` `{…}` — **аноним** |
| Заявки компании (фильтры + страницы) | `GET /form-leads` → `leads:read` |
| Одна заявка | `GET /form-leads/{id}` → `leads:read` |
| Выгрузка заявок в CSV | `GET /form-leads/export` → `leads:read` |
| Сменить статус заявки | `PATCH /form-leads/{id}` `{"status":"done"}` → `leads:write` |
| Удалить заявку (ЖЁСТКО, сразу) | `DELETE /form-leads/{id}` `{}` → `leads:write` |

⚠️ `DELETE` через шлюз требует тело — минимум `{}` (иначе gateway ответит EOF).
⚠️ `/form-leads/export` — статический сегмент, он матчится раньше
`/form-leads/{id}`; заявки с id `export` не существует.

**Конверты ответов (разные — смотри внимательно):**

| Ручка | Форма ответа |
|---|---|
| `GET /forms` | `{"result": [ … ], "total": N, "currentPage": N}` — массив прямо в `result` (НЕ в `items`), счётчики рядом |
| `GET /forms/{id}`, `POST /forms`, `PUT /forms/{id}` | `{"result": { …Form… }}` |
| `DELETE /forms/{id}` | `{"deleted_after": "2026-09-04T…Z"}` — БЕЗ `result` |
| `GET /forms/{id}/public` | `{"result": {"id","schema","is_active","banner_url"}}` |
| `POST /form-leads` | `{"ok": true, "success_text": "…"}` — БЕЗ `result` и БЕЗ id заявки |
| `GET /form-leads` | `{"result": {"items": […], "total": N, "currentPage": N}}` |
| `GET /form-leads/{id}`, `PATCH /form-leads/{id}` | `{"result": { …FormLead… }}` |
| `GET /form-leads/export` | `{"csv": "<base64 файла>", "rows": N}` — БЕЗ `result` |
| `DELETE /form-leads/{id}` | `{}` |

`Form`: `id, name, code, schema(base64), is_active, created_at, updated_at`.
`FormListItem` (строка списка, без схемы): `id, name, code, is_active,
fields_count, new_leads, updated_at` — `new_leads` считается по ПРОД-таблице
заявок в любом контуре.

⚠️ `GET /forms` отдаёт СТРАНИЦУ: без `page` — первые 50 форм, `page.limit`
жёстко ограничен сотней. Компания с сотнями форм без `page.number` увидит
только первую страницу — ориентируйся на `total`, а не на длину `result`.
`FormLead`: `id, form_id, form_name, values(base64), snapshot(base64), status,
page_url, referer, utm(base64), ip, user_agent, is_test, created_at`.
`form_name` берётся из `snapshot` заявки — она переживает переименование и
удаление формы, живого JOIN нет намеренно.

**Фильтры `GET /form-leads` и `GET /form-leads/export`** (одинаковые):
`filter.form_id` · `filter.status` (`new|in_progress|done|spam`) ·
`filter.from` / `filter.to` (RFC3339) · `filter.include_test` (по умолчанию
**false** — тестовые сабмиты дев-контура скрыты) · `filter.utm_source` /
`filter.utm_medium` (метки кампании, ≤200). Страницы — `page.number`
(с 1) и `page.limit` (1–100, дефолт 20); порядок — свежие сверху. Экспорт
страниц не имеет: он отдаёт до **10 000** строк одним файлом.

Про метки: совпадение **точное, но без учёта регистра** (`vk` = `VK`) и по
плоскому ключу узла `utm` — вложенный `utm.first` (первое касание) в отборе не
участвует. Пустая строка = фильтра нет; заявка **без** метки под фильтр по
источнику не попадает. Источник и канал складываются по И. Справочника
источников в API нет — значения придумывает продавец, когда верстает рекламную
ссылку, поэтому в UI это свободный ввод, а не селект.

### 13.3. Сценарий агента целиком

**Шаг 1. Создать форму.** `schema` опциональна (пусто = `{}` — пустая форма,
которую владелец достроит в админке).

```bash
POST /forms
{ "item": { "name": "Заявка на консультацию",
            "code": "consult",             # необязательный машинный код
            "is_active": true,
            "schema": "eyJ2IjoxLCJmaWVsZHMiOlt7ImlkIjoiZjEi…" } }
# → { "result": { "id": "7", "name": "…", "schema": "eyJ2…", "is_active": true, … } }
```

`schema` до кодирования (это и есть содержимое, разбор — §13.4):

```jsonc
{ "v": 1,
  "fields": [
    { "id": "f1", "kind": "text",    "key": "name",    "label": "Имя",     "required": true },
    { "id": "f2", "kind": "phone",   "key": "phone",   "label": "Телефон", "required": true,
      "placeholder": "+7 900 000-00-00" },
    { "id": "f3", "kind": "consent", "key": "consent", "required": true },
    { "id": "f4", "kind": "submit" }
  ],
  "layout":  { "mode": "vertical", "width": 460, "gap": 16, "align": "left" },
  "look":    { "size": "m", "radius": 24, "button": { "label": "Отправить", "width": "full" } },
  "texts":   { "title": "Оставьте заявку", "success": "Спасибо! Мы перезвоним." },
  "consent": { "enabled": true, "link": { "type": "url", "url": "/legal/privacy" } } }
```

⚠️ **`is_active` присылай явно.** Поле присутствия: не прислал в `POST` —
форма создаётся ВКЛЮЧЁННОЙ (так задумано: агент, не знающий про флаг, не должен
оставить владельцу форму, молча не принимающую заявки).

`PUT /forms/{id}` — **partial**: не прислал ключ — сервер его не трогает.
Прислал `schema` — она заменяется ЦЕЛИКОМ (read-modify-write: сперва `GET`,
потом шли всю схему). `"code": ""` = снять машинный код.

**Шаг 2. Поставить блок на страницу.** Секция обычная, `kind:"form"`; данных
формы в payload НЕТ — только ссылка:

```jsonc
POST /content-blocks
{ "item": { "name": "Форма: консультация",
  "sections": [ { "type": "text", "payload": { "v": 2, "kind": "form",
    "props": {
      "form": { "formId": 7 },            // ← число, id сущности forms
      "blockWidth": "content",            // сквозные props блока (§5)
      "marginTop": 32, "marginBottom": 32
    } } } ] } }
# → { "result": { "id": "141", … } }
```

Дальше — как с любым блоком (§6): добавить `141` в `refs` манифеста
`__page:top` и повторить replace-set привязки
`PUT /categories/{id}/content-blocks`. Сквозные настройки обёртки
(`blockWidth`/`contentWidth`/`marginTop`/`marginBottom`/`marginX`/`blockRadius`/
`visibility`/`tablet`/`mobile`) работают ровно как у остальных блоков —
справочник в §5. Внутреннее оформление (ширина карточки, скругление, цвета,
размер полей) живёт в схеме ФОРМЫ, а не в props блока.

⚠️ `formId` пустой или `0` → блок считается пустым: на витрине он не
отрисуется вовсе. Ставь блок только после того, как форма создана.

**Шаг 3. Прочитать заявки.**

```bash
GET /form-leads?filter.form_id=7&page.number=1&page.limit=50
# → { "result": { "items": [ { "id": "31", "form_id": "7", "form_name": "Заявка…",
#       "values": "eyJuYW1lIjoi…",  ← base64: {"name":"Иван","phone":"+7…"}
#       "status": "new", "page_url": "https://shop.vizen.shop/promo",
#       "utm": "eyJ1dG1fc291cmNlIjoi…", "ip": "203.0.113.0", "is_test": false,
#       "created_at": "2026-08-05T09:14:00Z" } ], "total": 12, "currentPage": 1 } }

PATCH /form-leads/31   { "status": "in_progress" }
GET   /form-leads/export?filter.form_id=7   # → {"csv":"<base64>","rows":12}
```

CSV: BOM UTF-8, разделитель `;`, переводы строк CRLF (Excel открывает без
плясок). Колонки — `created_at, status, form, page_url`, затем ОБЪЕДИНЕНИЕ
ключей полей по всей выборке (алфавитно), затем метки
`utm_source…ttclid` и их же копии с префиксом `first_`.

### 13.4. Схема формы (`forms.schema`)

Схема **аддитивна**: неизвестные ключи сервер сохраняет байт-в-байт и отдаёт
обратно — не выбрасывай то, чего не понимаешь. Читается витриной через
normalize: отсутствующее добивается дефолтом, незнакомый enum — безопасным
даунгрейдом, число вне диапазона — клампом.

| Узел | Ключи | Значения |
|---|---|---|
| — | `v` | версия схемы, сейчас `1` |
| `fields[]` | `id` | стабильный id элемента (`"f1"`, `"f2"`…) |
| | `kind` | `text · textarea · email · phone · number · select · radio · checkbox · consent · date · file · hidden · heading · description · divider · submit` |
| | `key` | машинный ключ значения: `^[a-z][a-z0-9_-]*$`, ≤64, **уникален в форме**. Обязателен у полей, собирающих значение; у служебных (`heading`/`description`/`divider`/`submit`) его нет |
| | `label` / `placeholder` | ≤255 символов |
| | `required` | bool — звёздочка + серверная проверка при приёме |
| | `value` | значение по умолчанию; у `hidden` — то, что уйдёт в заявку |
| | `options[]` | варианты `select`/`radio`/`checkbox`: ≤64 штук, каждый ≤255 |
| | `hidden` | bool — элемент скрыт глазиком, на витрине не рендерится |
| `layout` | `mode` | `vertical` (дефолт) · `inline` (поле + кнопка в строку) |
| | `width` / `gap` | 280–1200 (дефолт 460) / 0–48 (дефолт 16), px |
| | `align` | `left` (дефолт) · `center` · `right` |
| `banner` | `pos` | `none` (дефолт) · `top` · `bottom` |
| | `fileId` | uuid картинки из медиатеки — **только id**, URL не запекать |
| | `title` / `text` / `note` | тексты поверх картинки |
| `look` | `bg` / `backdrop` | объекты `Color` схемы стилей (дефолт `#ffffff` / `#18181b`); голый hex-строкой запрещён |
| | `radius` / `size` | 0–40 (дефолт 24) / `s`·`m`(дефолт)·`l` |
| | `showClose` | крестик, скрывающий карточку до конца визита (дефолт `false`) |
| | `button` | `{label, style (ButtonStyle), width: auto\|full (дефолт full)}` |
| `texts` | `title` / `text` / `success` | заголовок, текст под ним, ответ после отправки |
| `consent` | `enabled` / `label` / `link` | строка согласия 152-ФЗ; `link` — `LinkRef` (`{type:"url", url:"…"}` или `{type:"page", id:N}`) |
| `submit` | `targets[]` / `antispam` | каналы уведомлений `{type:"email"\|"telegram", address}`; пусто = e-mail владельца магазина. Всего адресатов ≤ **10** на оба канала |
| | `confirmToSender` / `confirmText` | bool — слать ли отправителю письмо «мы получили вашу заявку» (по умолчанию `false`) и текст этого письма от лица магазина (≤2000 символов) |

Жёсткие пределы записи (нарушил — `FORM_SCHEMA_INVALID`): вся схема ≤ **64 КБ**,
полей ≤ **64**, `kind` только из списка выше, `key` по грамматике и без дублей,
адресатов `submit.targets` ≤ **10** (адрес ≤255), `submit.confirmText` ≤ **2000**.

### 13.4а. Уведомления о заявке: почта, Telegram, подтверждение отправителю

Что происходит после принятой заявки (спам, тестовые сабмиты и повторы в окне
дедупа не уведомляются вовсе):

1. **Письмо продавцу** — на каждый `targets[]` типа `email`; список пуст →
   e-mail владельца магазина.
2. **Сообщение в Telegram** — на каждый `targets[]` типа `telegram`. `address` —
   это `chat_id` (у групп и каналов он отрицательный) или `@имя_канала`.
   ⚠️ **Токен бота в схему не кладут** — он настройка МАГАЗИНА, а не формы:
   схема публична (`GET /forms/{id}/public`), и токен в ней утёк бы любому
   посетителю витрины. Бот подключается отдельной парой ручек:

   ```bash
   PUT /v1/orgs/{id}/telegram   { "bot_token": "8123456789:AA…" }
   # → { "result": { "connected": true, "token_mask": "8123456789:***KlM", "updated_at": "…" } }
   GET /v1/orgs/{id}/telegram   # то же тело
   PUT /v1/orgs/{id}/telegram   { "bot_token": "" }   # отключить бота
   ```

   Ручки требуют роли **owner|admin** в магазине и **по PAT недоступны** —
   только живой сессией. Сам токен не возвращает ни одна ручка: наружу уходят
   только `connected` и маска. Бот в форме указан, а у магазина не подключён →
   заявка принимается как обычно, уведомление просто не уходит.
3. **Письмо-подтверждение отправителю** — если в схеме `submit.confirmToSender:
   true`. Адрес берётся из значения поля `kind:"email"` этой же заявки (нет
   такого поля или значение пустое — письма нет, ошибки тоже нет). Не больше
   **200** подтверждений на форму в сутки: ручка публичная и анонимная, и без
   этого потолка форма была бы рассылочным шлюзом. Потолок режет только
   письмо — заявка и уведомление продавцу проходят.

⚠️ **Что НЕ отдаётся публично.** `GET /forms/{id}/public` вырезает узел
`submit` (адресаты уведомлений — секрет владельца) и не содержит `code`
(это колонка формы, а не часть схемы). Всё остальное, включая незнакомые
серверу ключи, уходит как есть. Не клади в схему ничего чувствительного:
она по определению публична.

### 13.5. Публичные ручки (то, что делает витрина)

Обе доступны **анониму** — токен не нужен. С токеном они работают тоже, но
владельческий (дев-)вид на `GET /forms/{id}/public` открывает только скоуп
`forms:read`; без него ключ увидит ровно то же, что браузер без ключа.

```bash
GET /forms/7/public
# → { "result": { "id": "7", "schema": "<base64 очищенной схемы>",
#                 "is_active": true, "banner_url": "/files/…webp" } }
```

`banner_url` — серверный резолв `banner.fileId`; чужой или мёртвый uuid тихо
даёт пустую строку. Форма удалена, компания скрыта или её нет →
`404 FORM_NOT_FOUND` (маска: существование не палится).

```bash
POST /form-leads
{ "form_id": 7,
  "values": "eyJuYW1lIjoi0JjQstCw0L0iLCJwaG9uZSI6Iis3OTAwMDAwMDAwMCJ9",  # base64 {"name":"Иван","phone":"+79000000000"}
  "page_url": "https://myshop.vizen.shop/promo-vesna",
  "referer":  "https://yandex.ru/",
  "utm": "eyJ1dG1fc291cmNlIjoieWFuZGV4In0=",   # base64 {"utm_source":"yandex"}
  "hp": "",            # honeypot: должно остаться ПУСТЫМ
  "elapsed_ms": 8400 } # сколько человек заполнял форму, мс
# → { "ok": true, "success_text": "Спасибо! Мы перезвоним." }
```

Что делает сервер (важно знать, чтобы не удивляться ответам):

- **`company_id` берётся ИЗ ФОРМЫ.** В запросе его нет вовсе — подделать
  тенанта нечем.
- **Whitelist ключей `values` по схеме.** Проходят только ключи полей,
  собирающих значение; служебные виды и `file` (вложения в v1 не принимаются)
  отбрасываются, значения-нестроки отбрасываются молча, длинные режутся до
  4096 символов. Всё тело — ≤64 КБ.
- **Валидация реальной заявки:** непустые `required`, мягкая проверка формата
  `email` (есть `@` и точка) и `phone` (≥7 цифр), а при `consent.enabled` —
  обязательная отметка согласия. Не прошло → `400 FORM_VALIDATION_FAILED`.
- **Honeypot и время.** Непустой `hp` ИЛИ `elapsed_ms` в диапазоне 1–1999
  (быстрее 2 секунд) = бот: ответ обычный «спасибо», но заявка пишется со
  `status:"spam"`. Боту не подсказываем, что он раскрыт. Своими руками эти
  поля не заполняй — `hp` оставляй пустым, `elapsed_ms` считай честно от
  показа формы.
- **Выключенная форма — не ошибка:** `is_active:false` → `200 {"ok": false}`
  без `success_text`, заявка не пишется. Человеческий текст «приём закрыт»
  рисует витрина (у неё дефолты локализованы) — сервер своих фраз не
  сочиняет, `success_text` в ответе только тот, что владелец задал в
  `texts.success`.
- **Суточный потолок формы** (по умолчанию 500 реальных заявок в сутки):
  сверх него ответ тот же `{"ok": true}`, но заявка НЕ пишется.
- **Повтор схлопывается на сервере.** Те же `values` по той же форме внутри
  МИНУТЫ считаются одной заявкой: ответ тот же `{"ok": true}` с тем же
  `success_text`, но второй строки, второго письма продавцу и второго вебхука
  не будет. Ретрай после таймаута безопасен, идемпотентный ключ слать не
  нужно — его никто не ждёт. Другой человек (другие значения) дедуп не
  трогает, и форма без единого заполненного значения не дедупится вовсе.
- **Метки маркетинга — по whitelist:** `utm_source`, `utm_medium`,
  `utm_campaign`, `utm_content`, `utm_term`, `gclid`, `yclid`, `fbclid`,
  `ysclid`, `ttclid` (значения ≤200 символов); вложенный узел `first` —
  first-touch по тем же правилам. Всё прочее выбрасывается.
- **Ответ никогда не содержит id заявки.** Прочитать чужую заявку по
  публичной ручке нечем.
- **Уведомления уходят только по РЕАЛЬНОЙ заявке** (не спам, не тестовый
  сабмит дев-контура): письмо продавцу по `submit.targets` (пусто = e-mail
  владельца магазина) и вебхук `lead.created` (§13.7). Сбой почты не
  превращает принятую заявку в ошибку.
- **ПДн:** IP усекается ПРИ ЗАПИСИ (IPv4 → /24, IPv6 → /48) — полный адрес не
  хранится; текст согласия и ссылка на политику попадают в `snapshot` заявки с
  моментом акцепта; заявки старше срока хранения (по умолчанию 365 дней)
  жёстко удаляются суточным сборщиком. `DELETE /form-leads/{id}` удаляет
  заявку СРАЗУ и насовсем — окна отсрочки, в отличие от формы, нет.

### 13.6. Ошибки

| Ситуация | HTTP | Маркер в `desc` |
|---|---|---|
| Нет нужного скоупа | 403 | `PAT_SCOPE_MISSING (required_scope: forms:write \| leads:read \| …)` |
| Форма чужая/удалённая/нет; компания скрыта | 404 | `FORM_NOT_FOUND` |
| Заявка чужая/нет | 404 | `FORM_LEAD_NOT_FOUND` |
| Схема не прошла проверку | 400 | `FORM_SCHEMA_INVALID: <что именно>` (≤64 КБ, ≤64 полей, `kind` из списка, `key` по `^[a-z][a-z0-9_-]*$` и без дублей, `label`/`placeholder`/вариант ≤255, `options` ≤64) |
| `code` формы занят в этой компании | 409 | `FORM_CODE_TAKEN` |
| Тариф: живых форм уже столько, сколько разрешено (`forms_max`) | 429 | `FORM_LIMIT_REACHED` — лимит СЧИТАЕТСЯ в твоём контуре: черновиком его не обойти. Удали ненужные формы или скажи владельцу про тариф |
| Приём заявки не прошёл валидацию | 400 | `FORM_VALIDATION_FAILED` |
| Статус заявки не из `new\|in_progress\|done\|spam` | 400 | ошибка валидатора / `invalid lead status` |
| Слишком часто шлёшь заявки с одного адреса | 429 | `RATE_LIMITED: retry after Ns` + заголовок `Retry-After`. У публичного приёма СВОЙ лимитер, по IP и жёсткий (порядок единиц в минуту) — он про антиспам, а не про твои интеграции |
| Схема/тело больше 64 КБ, `page_url`/`referer` > 2048 | 400 | сообщение валидатора транспорта |

### 13.7. Вебхук `lead.created`

Новая **реальная** заявка (не спам и не тестовый сабмит дев-контура) уходит
подпиской Б3 — тем же механизмом, что `order.created` (HMAC-SHA256 в
`X-Vizen-Signature`, ретраи). Отдельного «коннектора CRM» нет и не будет:
дубль лида на сторону делается этим вебхуком. Состав `payload`:

```jsonc
{ "lead_id": 31,
  "form_id": 7,
  "form_name": "Заявка на консультацию",
  "values": { "name": "Иван", "phone": "+79000000000" },  // уже разобранный объект
  "page_url": "https://myshop.vizen.shop/promo-vesna",
  "utm": { "utm_source": "yandex", "first": { "utm_source": "google" } },  // null, если меток нет
  "created_at": "2026-08-05T09:14:00Z" }
```

⚠️ **Тестовая отправка из редактора формы (§13.7а) тоже шлёт этот вебхук** — с полем
`"test": true` и без `page_url`. Приёмник (CRM) обязан проверять `test` и не заводить по нему сделку.

Подписка на события — раздел `/api-access` в админке магазина (по API
вебхуками управлять нельзя).

### 13.7а. Песочницы (доски заявок), стадии, тест, спам — ручки CRM

Заявка падает не «в список», а в **песочницу** (доску-канбан) формы, в её колонку-приём (intake).
У компании всегда есть дефолтная песочница; форма привязана к одной (`sandbox_id` формы, пусто =
дефолтная). Всё ниже — приватные ручки (`forms:*` для песочниц/колонок, `leads:*` для заявок), префикс БЕЗ `/v1`.

| Что нужно | Запрос |
|---|---|
| Песочницы С колонками (одним запросом) | `GET /sandboxes` → `forms:read` — `{"result":[{id,name,is_default,sort_order,columns:[{id,label,kind:"intake"\|"normal",sort_order}]}]}` |
| Создать / переименовать / удалить песочницу | `POST /sandboxes {name}` · `PUT /sandboxes/{id} {name}` · `DELETE /sandboxes/{id} {}` → `forms:write` (при удалении заявки переезжают в дефолтную) |
| Порядок песочниц | `POST /sandboxes/reorder {ids:[…]}` → `forms:write` |
| Колонки: добавить / переименовать / удалить / порядок | `POST /sandboxes/{sandbox_id}/columns {label}` · `PUT /sandbox-columns/{id} {label}` · `DELETE /sandbox-columns/{id} {}` · `POST /sandboxes/{sandbox_id}/columns/reorder {ids}` → `forms:write` (intake ровно одна, её не удалить) |
| Привязать форму к песочнице | `PUT /forms/{id} {"item":{"sandbox_id": 3}}` → `forms:write` |
| Перенести заявку по стадиям | `PATCH /form-leads/{id}/column {column_id}` · массово `POST /form-leads/bulk/column {ids, column_id}` → `leads:write` |
| Массовое удаление | `POST /form-leads/bulk/delete {ids}` → `leads:write` (жёстко, сразу) |
| Спам: пометить / снять | `POST /form-leads/{id}/spam {is_spam:true\|false}` → `leads:write` (обратимо; спам не уведомляет и не дедупится) |
| Ручная заявка (звонок, оффлайн) | `POST /form-leads/manual {sandbox_id, name, phone, email, comment}` → `leads:write` (падает в intake песочницы, `form_id=0`) |
| **Тестовая отправка формы** | `POST /forms/{form_id}/test-lead {form_id}` → `forms:write` — кладёт заявку с `is_test:true` в intake песочницы формы И шлёт уведомления с меткой [ТЕСТ] (вебхук приходит с `test:true`). В списках тестовые скрыты, пока не передан `filter.include_test=true` |
| Где используется форма (перед удалением) | `GET /forms/{id}/usage` → `forms:read` — `{"usages":[{entity_type, entity_id, title}]}` |

Фильтры `GET /form-leads` дополнительно к §13.2: `filter.sandbox_id`, `filter.column_id`, `filter.is_spam`.
В ответе заявки есть `sandbox_id`, `column_id`, `column_label`, `is_spam`. Экспорт CSV показывает стадию
канбана (`stage`).

### 13.8. Чек-лист «форма готова»

- [ ] Форма создана, `is_active: true`, в `GET /forms` видна с ожидаемым
      `fields_count`.
- [ ] У каждого поля-сборщика есть `key` (латиница, без дублей) — иначе
      значения не попадут в заявку.
- [ ] Есть элемент `kind:"submit"` (кнопка) и, если собираешь ПДн, —
      `consent.enabled: true` со ссылкой на политику.
- [ ] `GET /forms/{id}/public` БЕЗ токена отдаёт схему, и в ней НЕТ `submit`.
- [ ] Блок с `props.form.formId` создан, добавлен в `refs` манифеста
      `__page:top` и привязан replace-set'ом.
- [ ] Тестовый `POST /form-leads` вернул `{"ok": true}`, а заявка видна в
      `GET /form-leads?filter.form_id={id}` (в дев-контуре — с
      `filter.include_test=true`).
- [ ] Владельцу сказано, где смотреть заявки (админка → «Формы» → «Заявки»)
      и что тестовые сабмиты скрыты по умолчанию.

## 14. КНОПКА → ФОРМА из твоего HTML: калькуляторы и интерактив (уровень 2)

Лид-форму не верстают руками (§13) — её ВЫЗЫВАЮТ. У платформы один механизм
открытия формы поверх страницы (поп-ап): кнопки готовых блоков (обложка, шапка,
меню, карточки, CTA) ссылаются на форму по её id, и тот же поп-ап доступен из
твоего HTML-документа уровня 2 через мост `postMessage`. Второй вёрстки формы
нет: открывается та же карточка, что и блоком, с теми же полями, антиспамом,
метками и аналитикой.

### 14.1. Мост из iframe уровня 2 (скрипты твои, приём — платформы)

Документ уровня 2 живёт в iframe-песочнице (§8). Из него доступны три сообщения
родителю; адресат — `parent`, origin `'*'` (у песочницы он непрозрачный):

| Сообщение из твоего кода | Что делает платформа |
|---|---|
| `parent.postMessage({ type: 'vizen-form-open', id: 7, prefill: { calc_total: '124 500' } }, '*')` | открывает форму `7` поп-апом; `prefill` подставляется в поля формы (в т.ч. скрытые `kind:"hidden"`) |
| `parent.postMessage({ type: 'vizen-form-submit', id: 7, values: { name: '…', phone: '…', calc_total: '…' }, reqId: 'a1' }, '*')` | отправляет заявку БЕЗ окна — тем же путём, что кнопка формы: `page_url`, метки, honeypot и время на странице подставляет страница-хозяин |
| ← `{ type: 'vizen-form-result', reqId: 'a1', ok: true, success_text: '…' }` (или `ok:false, error:'network'`) | ответ на `vizen-form-submit` приходит `message`-событием обратно в твой iframe |

Правила (нарушил — сообщение молча отброшено, в консоли `warn`):
- `id` — число (id формы), не строка; форма должна быть создана (§13.3) и включена;
- ключи `prefill`/`values` — ТОЛЬКО ключи полей ЭТОЙ формы (`^[a-z][a-z0-9_-]*$`,
  ≤64 символов); чужие ключи отбрасываются и здесь, и на сервере; значения — строки
  ≤4096; пар ≤64; всё сообщение ≤64 КБ; `reqId` — строка ≤128 (чтобы сопоставить ответ);
- расчёт калькулятора кладётся в поля схемы формы: заведи в форме скрытые поля
  (`kind:"hidden"`, например `calc_total`, `calc_config`) — они уйдут в заявку, в CSV и в вебхук;
- `vizen-form-submit` обязан нести все обязательные поля формы (`required`), а при
  включённом согласии — непустой `consent`; иначе сервер ответит `400`, и в iframe
  придёт `{ok:false, error:'validation'}` (повтор без исправления данных бесполезен;
  `error:'network'` — можно повторить); согласие prefill'ом не проставляется — его
  ставит только сам покупатель;
- `vizen-form-submit` раньше чем через 2 с после загрузки страницы сервер помечает
  как спам (антибот, §13.5) — не шли заявку «на лету» при открытии;
- повтор тех же `values` в течение минуты схлопывается сервером (§13.5) — ретрай безопасен;
- письмо/Telegram/вебхук `lead.created` уходят по обычным правилам формы (§13.4а, §13.7).

Мини-пример калькулятора:

```html
<button id="go">Рассчитать и оставить заявку</button>
<script>
  document.getElementById('go').onclick = () => {
    const total = 124500; // твой расчёт
    parent.postMessage({ type: 'vizen-form-open', id: 7,
      prefill: { calc_total: String(total), calc_config: 'угловая, 3.2 м' } }, '*');
  };
  addEventListener('message', (e) => {
    if (e.data && e.data.type === 'vizen-form-result') console.log('заявка:', e.data.ok);
  });
</script>
```

Проверка глазами: страница на витрине → клик по твоей кнопке → поп-ап формы с
подставленными значениями → после отправки заявка видна в `GET /form-leads`.

### 14.2. Кнопка-открыватель в HTML уровня 1

Кнопки готовых блоков платформы (обложка, шапка, меню, карточки, CTA) открывают
форму сами — выбери форму в настройках кнопки. Собственная ссылка-открыватель
внутри HTML-документа уровня 1 (`<a href="form:<id>">`) тоже работает:
каноничный `form:<id>` проходит очистку, а битая схема отбрасывается (§8а).

### 14.3. Модальное окно из HTML: товар или готовая дизайн-группа

#### Товар: оставь настоящий URL

Сохрани обычную ссылку на товар и добавь id для quick view:

```html
<a href="/catalog/chair" vz-quickview="42">Кресло</a>
```

Обычный клик откроет живое тело страницы товара в окне; Cmd/Ctrl+клик,
средняя кнопка и работа без JavaScript сохраняют обычный переход по `href`.
Для полной команды используй `href="modal:product:42?size=full"` или тот же
адрес в `vz-modal`. Параметры: `tpl=<id товарной группы body>`,
`size=small|std|full`, `nav=ids:42,43`, `v=<sku>`. Без `tpl` действует обычная
лестница оформления страницы товара.

#### Готовый внутренний контент: группа дизайна

Модальное содержимое не передают строкой HTML в открывателе и не загружают
произвольным публичным `GET group by id`. Его собирают стандартными блоками,
объединяют в группу, а кнопка ссылается на группу:

```html
<button type="button" vz-modal="modal:group:1727?size=small">
  Подробнее об акции
</button>

<!-- Для ссылки допустима та же команда прямо в href: -->
<a href="modal:group:1727?size=full">Открыть презентацию</a>
```

Как создать содержимое запросами API:

```http
# 1. Создай один или несколько обычных блоков-виджетов (§5).
POST /content-blocks
{ "item": { "name": "Тело акции", "sections": [ ... ] } }
# → result.id = 1726

# 2. Собери их в дизайн-группу. Порядок widgets = порядок внутри окна.
POST /content-blocks
{
  "item": {
    "name": "Модалка акции",
    "group_type": "design",
    "sections": [{
      "type": "text",
      "payload": { "v": 2, "kind": "group", "props": { "widgets": [1726] } }
    }]
  }
}
# → result.id = "1727" (int64 в JSON может быть строкой)
```

Если внутренность должна быть полностью авторской, блок `1726` делай
`kind:"html"` по §2–§5 и оформляй HTML/CSS внутри него. Заголовок окна,
overlay, закрытие, focus и адаптивный sheet принадлежат платформе; содержимое
группы — тебе. Отступы/радиус отдельных блоков внутри задаются стандартными
props обёртки (§17), а не CSS-селекторами родительского окна.

#### Объяви зависимость в payload HTML-блока

Backend отдаёт только группы, на которые ссылается уже публичная страница. Для
HTML-документа цель нужно объявить структурированно рядом с документом — это
не второй открыватель, а список серверных зависимостей:

```jsonc
{
  "type": "text",
  "payload": {
    "v": 2,
    "kind": "html",
    "props": {
      "html": {
        "html_document_id": 31,
        "modalTargets": [{ "type": "group", "id": 1727 }]
      }
    }
  }
}
```

Это обязательно для `modal:group` из HTML, особенно level=2: его исходник
живёт файлом и может вычислять id скриптом, поэтому сервер не угадывает цель по
тексту. Quick view товара в `modalTargets` не нужен — товар имеет собственный
публичный маршрут. На одну страницу материализуется не больше 20 групп и 100
их виджетов; чужая, битая или feed-группа пропускается.

В полном ответе страницы (`GET /products/{id}`, `/categories/by-slug/...`,
`/articles/by-slug/...`) готовый контент приезжает сиблингом:

```jsonc
"modal_groups": [{
  "id": "1727",
  "name": "Модалка акции",
  "blocks": [{ "id": "1726", "sections": [ ... ] }]
}]
```

Отдельно запрашивать группу из браузера не надо и нельзя: storefront уже
зарегистрирует `modal_groups` в единственном modal-host страницы.

#### Уровень 2: та же разметка или программный вызов

В sandbox level=2 обычный DOM-клик не всплывает в страницу. Платформа сама
инжектит безопасный мост для `[vz-quickview]`, `[vz-modal]` и
`a[href^="modal:"]`, поэтому показанные выше кнопки работают без твоего JS.
Если цель вычисляется после запроса к публичному backend, отправь ту же
каноничную строку вручную:

```js
parent.postMessage({
  type: 'vizen-content-modal-open',
  href: 'modal:group:1727?size=std'
}, '*');

// Товар + настоящий fallback URL:
parent.postMessage({
  type: 'vizen-content-modal-open',
  href: 'modal:product:42?size=full',
  resourceHref: '/catalog/chair'
}, '*');
```

Сообщение проходит тот же строгий parser, что `href`: неизвестный kind, нулевой
id, размер вне `small|std|full`, управляющие символы и слишком длинные строки
отбрасываются. PAT/админский токен в браузерный HTML не помещай: им создаёт
блоки и группы агент или твой сервер; посетитель читает только публичный ответ
страницы. Неизвестный безопасный query-параметр игнорируется для совместимости.

## 15. ДИЗАЙН СТРАНИЦЫ: увидел блок → нашёл источник → поменял

Раздел отвечает на вопрос, который раньше решался догадками: «вижу на сайте
шапку с меню — что это и где это править». Порядок такой.

### 15.1. Со страницы читаешь метки

Каждый блок витрины помечен в разметке:

| Атрибут | Что означает |
|---|---|
| `data-vz-kind` | вид виджета (`siteHeader`, `siteMenu`, `productListing`, …) |
| `data-vz-block` | id дизайн-блока, из которого блок приехал |
| `data-vz-kit` | id КОМПЛЕКТА хрома — вместо `data-vz-block` у шапки/меню/футера |
| `data-vz-idx` | позиция секции внутри блока (или комплекта) |

⚠️ **У хрома номера блока-источника нет.** Витрина получает комплект уже
собранным, и собственные блоки шапки, меню и футера в ответе не названы —
поэтому там стоит `data-vz-kit`. Чтобы найти правимый блок части, прочитай
блок-комплект: в его секции `kind: "chromeKit"` лежит `props.kit` вида
`{"top": [2084, 2085], "bottom": [2086]}` — это и есть блоки шапки, меню и
футера по зонам, в порядке вывода.

```bash
curl -s https://<магазин>.vizen.shop/<страница> | grep -o 'data-vz-[a-z]*="[^"]*"'
```

Элементы ВНУТРИ виджета помечены отдельно — `data-el` (логотип, навигация,
иконки) и `data-edit` (текст, правимый на месте). По ним ты адресуешь правку
точнее, чем «где-то в шапке».

### 15.2. Спрашиваешь свойства виджета

```bash
curl -s https://<магазин>.vizen.shop/api/widgets
```

Справочник ПЛАТФОРМЫ (токен не нужен): для каждого вида — подпись RU/EN, полоса
это хрома или блок страницы, и полный список свойств с дефолтами. Свойства там
не переписаны руками: их отдаёт собственный разбор виджета, поэтому список не
может разойтись с тем, что понимает витрина.

### 15.3. Берёшь блок и правишь нужную секцию

```bash
# 1) блок целиком (право catalog:read)
curl -s -H "Authorization: Bearer $VZ" $API/content-blocks

# 2) правка: секции — replace-set, поэтому присылай ВЕСЬ список,
#    поменяв в нём секцию № data-vz-idx.
#    Метод именно PUT (не PATCH): partial-update здесь выражен полем item.
curl -s -X PUT -H "Authorization: Bearer $VZ" -H 'Content-Type: application/json' \
  $API/content-blocks/<data-vz-block> -d '{ "item": { "sections": [ … ] } }'
```

⚠️ `sections` — **replace-set**, а не частичная правка: пришлёшь одну секцию —
остальные исчезнут. Всегда читай блок, меняй нужный элемент списка, отправляй
список целиком.

⚠️ Право `catalog:write` обязательно. Живой сайт правится ключом контура `prod`;
дев-ключ пишет в черновик, а публикует человек (право `publish:write` выдаётся
отдельно, галочкой при выпуске токена).

### 15.4. Проверяешь глазами

Открой ту же страницу и убедись, что `data-vz-block` у изменённого блока прежний,
а содержимое новое. Если блок пропал — почти всегда это replace-set из 15.3.

## 16. СЛОИ СТРАНИЦЫ: своя вёрстка вместо чужого, а не поверх чужого

Страница собрана из трёх слоёв, и каждая настройка выключает СЛОЙ ЦЕЛИКОМ.
Отдельных выключателей на виджеты (например, «скрыть хлебные крошки») нет и не
будет: крошки — обычный виджет, они могут стоять в любом слое и исчезают вместе
со своим слоем.

| Слой | Что внутри | Флаг |
|---|---|---|
| Лейаут, верх | шапка, меню и всё, что владелец добавил в верхнюю зону | `hideSiteHeader` |
| Лейаут, низ | футер и всё, что добавлено в нижнюю зону | `hideSiteFooter` |
| Технический | всё, что пришло вместе с товаром или разделом: карточка, список товаров, хлебные крошки, заголовок страницы | `hideSystemBlock` |
| Свои блоки | то, что ты поставил сам | флага нет — лишнее просто удали |

Флаги лежат в секции `kind:"zone"` блока с именем `__page:top`.

### 16.1. Сценарий А — уникальный лендинг (ОДНА страница)

Создай пустую страницу и погаси на ней все три слоя. Тогда твоя вёрстка занимает
страницу целиком и ничего платформенного сквозь неё не проступает.

```bash
# 1) читаем блок-зону страницы (в data-vz-block его id, или ищи имя __page:top)
curl -s -H "Authorization: Bearer $VZ" $API/content-blocks

# 2) гасим слои — sections остаётся replace-set, шли ВЕСЬ список
curl -s -X PUT -H "Authorization: Bearer $VZ" -H 'Content-Type: application/json' \
  $API/content-blocks/<id> -d '{"item":{"sections":[
    {"kind":"zone","refs":[<id твоих блоков>],
     "page":{"hideSiteHeader":true,"hideSiteFooter":true,"hideSystemBlock":true}}
  ]}}'
```

Ничего при этом не удаляется: снимешь флаг — слой вернётся как был.

### 16.2. Сценарий Б — уникальная ШАПКА (весь сайт)

⚠️ Частая и дорогая ошибка: сверстать свою шапку внутри лендинга. Тогда она
появится на ОДНОЙ странице, а на всех остальных останется прежняя — на сайте
окажется две разные шапки.

Шапка живёт в **лейауте**, в зоне `top` (футер — в `bottom`). Части лейаута общие
между страницами, поэтому правка расходится по всему магазину сразу. Комплект
хрома — секция `kind:"chromeKit"`, `props.kit = {top:[id…], bottom:[id…]}`;
на странице его части помечены `data-vz-kit`.

Правило выбора: **одна страница → гаси слои на странице; весь сайт → правь
лейаут.**

⚠️ Слово «лейаут» в этом разделе — про ЗОНЫ хрома (комплект `chromeKit`,
магазины старой модели). Начиная с 2026-09-12 у платформы есть ЛЕЙАУТ САЙТА —
группа `group_type:"theme"` с собственной папкой файлов и кода (§2б). Шапка и
футер выбираются в нём слотами `header`/`footer`, а общий CSS/JS/шрифты сайта
кладутся в его папку, а не внутрь html-виджета шапки: на живом сайте CSS
шапки уровня 3 ложится на всю страницу, но кадр редактора грузит только
`<head>` лейаута, лейаут подключается до первого кадра, а шапка, снятая с
раздела, уносит стили с собой.

## 17. ОБЁРТКА БЛОКА: ширина, отступы, радиус — тоже через токен

Каждый блок страницы платформа рисует внутри стандартной обёртки, и она
настраивается ТОЙ ЖЕ секцией, что и сам виджет. Ключи лежат рядом с `kind`, а не
внутри `writePath` виджета:

```json
{ "kind": "html",
  "props": {
    "blockWidth": "full",
    "paddingSides": { "top": 0, "right": 0, "bottom": 0, "left": 0 },
    "blockRadius": 0,
    "html": { "html_document_id": 13 } } }
```

⚠️ Оси лежат в КОРНЕ `props` — рядом с гнездом виджета (`html`, `listing`, …), а
не рядом с `kind`. Ось, положенная на секцию, сохраняется и не рисует ничего:
`POST /docs/validate` отвечает на неё `ignored` с подсказкой «its place is
props.<ось>». Проверяйте этим, а не глазами.

Полный список осей с типами и дефолтами — `GET /docs/widgets`, раздел `wrapper`.
Числа оттуда БЕРУТ, а не помнят: у каждой оси и у каждого дефолта самих div-ов
обёртки в ответе стоит `anchor.at` — `файл:строка` витрины, которая это значение
задаёт. Справочник пересобирается из кода на каждой сборке.

```bash
curl -s https://api.vizen.shop/docs/widgets | jq -r '
  .wrapper.axes[] | [.path, .type, (.default // "-"), .anchor.at] | @tsv'
```

Там же `wrapper.ownDefaults`: виды, у которых дефолты СВОИ.

**HTML-блок — как раз такой вид.** У него платформенные отступы и радиус равны
нулю: видом распоряжается автор вёрстки. Раньше вокруг лендинга появлялся поясок
24/12 и скруглялись углы полосы во всю ширину — притом что в данных блока не было
ничего, кроме ширины. Нужен воздух — задайте `paddingSides` явно, он победит.

Адаптив: `props.tablet` и `props.mobile` с теми же ключами
(`props.mobile.paddingSides`); незаданный слой берёт значение более широкого.

⚠️ `sections` — replace-set: читайте блок, меняйте нужный элемент, отправляйте
список целиком.

### 17.0. Свой класс на обёртке: как сделать блок непохожим на платформу

Обёртка — это ДВА div-а, и у каждого свои дефолты:
`div.vz-box` (полоса: фон, рамки, отступы, радиус, липкость — и она НЕ режет
ничего по умолчанию) → `div.vz-inner` (контейнер: кап ширины по колонке сайта и
центрирование). У html-блока **уровня 1** внутри ещё третий — хост Shadow DOM
`div.vz-radius`, и вот ОН режет по умолчанию: выпадашка и модалка из авторской
вёрстки обрезаются по краю блока, пока не задан `props.overflow: "visible"`.
У перенесённой папки уровня 3 без `props.html.boxed` нет ни одного из трёх
узлов: твой корневой элемент и есть блок, обёртке не на что ложиться.

Два пути уникализации, выбирать ДО вёрстки:

| Что нужно | Путь |
|---|---|
| перевёрстывать блок целиком — своя сетка, свои брейкпоинты | **`props.wrapperClass`** — свой класс на полосе, дальше свой CSS |
| выключить ОДНО поведение (обрезку, кап ширины, воздух, слой) | **точечная ось** — `props.overflow`, `props.blockWidth`, `props.paddingSides`, `props.position` … |

```jsonc
{ "kind": "html",
  "props": {
    "wrapperClass": "promo-hero grid-2",   // до 3 имён через пробел
    "wrapperId": "prices",                 // одно имя: #якорь и свои скрипты
    "html": { "html_document_id": 13 } } }
```

В разметку свои имена приходят ПОСЛЕДНИМИ, платформенные остаются на месте:

```html
<div class="vz-box @container vz-edge promo-hero grid-2" id="prices">
  <div class="vz-inner">…</div>
</div>
```

```css
.promo-hero             { padding: 0; background: #0b0b12 }
.promo-hero > .vz-inner { max-width: none; display: grid;
                          grid-template-columns: 1fr 1fr; gap: 48px }
```

Имена проверяются ЦЕЛИКОМ и целиком же отбрасываются: до 3 классов, один id,
`^[a-zA-Z_][a-zA-Z0-9_-]*$`, до 48 символов, и ни одно не может начинаться с
`vz-` — это пространство платформы (`vz-edge` расширил бы блок мимо
`blockWidth`, `vz-vis-none` спрятал бы его целиком). Ошибка в имени стоит вам
класса, но не ломает страницу.

**Работает и у хрома.** `siteHeader`, `siteMenu`, `siteFooter` рисуют полосу
сами, но читают те же `wrapperClass`/`wrapperId`: платформенную шапку можно
перекрасить и перевёрстывать, не меняя её на свою вёрстку и не теряя живой поиск
и счётчик корзины.

⚠️ **CSS для своего класса обязан лежать в документе УРОВНЯ 3.** Стили документа
уровня 1 живут внутри Shadow DOM и `.vz-box` не видят вообще — вы поставите
класс, напишете правило и не увидите ничего. Уровень 3 — это настоящий DOM
страницы; нужны роль owner/admin и серверный флаг (на проде включён; отказ —
значит стенд без флага или чужая роль, сообщите владельцу). Перенесённая папка
— уровень 3 по определению.

⚠️ **Не цепляйтесь за служебные классы платформы** — `.vz-box`, `.vz-inner`,
`.vz-radius`, `.vz-edge`, `.vz-vis-*`. Они не контракт, версии у них нет, и они
уже переезжали: до 2026-08-14 контейнер содержимого рисовали три виджета по
отдельности, а у ленты блоков был свой див-обёртка — и CSS, написанный по этим
именам, тихо ломался. Именно поэтому появился `wrapperClass`. Всегда через своё
имя: `.promo-hero > .vz-inner`, а не голый `.vz-inner`.

### 17.0.1. Липкость

`props.position: "sticky"` (+ `props.stickyTop` в px, если сверху уже что-то
липкое) делает липкой САМУ ПОЛОСУ — единственный узел, которому есть куда ехать.
Своя вёрстка просит того же ключом `vz-sticky` на корневом узле документа.

Где работает: любой блок зоны страницы, любой блок зоны лейаута (комплект хрома
— это и есть случай «своя шапка»), вложенные блоки колонок. Где НЕ работает:
платформенные `siteHeader`/`siteMenu`/`siteFooter` — их полосу обёртка не рисует
вовсе, у шапки для этого своя настройка `props.sticky: true`; `vz-sticky` внутри
колонок (там спрашивают только `props.position`); канва редактора (превью не
спрашивает, живой сайт — да).

⚠️ `props.overflow: "clip"` и липкость несовместимы: обрезающий предок не тот,
к кому можно прилипнуть. Выбирайте одно на блок. И выбросьте старые заметки про
`position: fixed` с распоркой по высоте шапки — обходной путь больше не нужен.
Для липкой шапки `position: fixed` не инструмент и на уровне 3: он не
резервирует высоту, `vz-sticky` резервирует. Рейлы, модалки и оверлеи в
перенесённой папке на `fixed` работают от окна.

### 17.1. Картинки в авторской вёрстке

Пишите обычный `<img src>` на файл магазина — сервер сам подменит его на
нарезчик и добавит `srcset` (640/1024/1600, webp). Оригинал в разметку не
попадает.

**Но `sizes` задавайте сами.** Ширину слота знает только автор; без подсказки
сервер ставит `100vw`, и браузер берёт САМУЮ крупную ступень. На сетке карточек
шириной 268px это означало 1600w вместо 640w — шесть лишних мегабайт:

```html
<img src="/files/…/item.png"
     sizes="(min-width: 1228px) 280px, (min-width: 900px) 32vw, 47vw">
```

Свой `sizes` сервер не трогает. Свой `srcset` — тоже: тогда подмена не делается
вообще, адаптив целиком ваш.
