# Web coding guide: build a shop page with code

> For an AI agent or developer with a token: **lay out an HTML page
> (landing, promo, quiz) and publish it in a Vizen shop** — no admin panel,
> API calls only. Every request shape below was verified with live calls.
> Companion document — the structure of page responses:
> `vizen-market/app/docs/html-редактор/06-структура-страниц.md`.

> **Filling the catalogue rather than laying out a page?** You need the sibling guide:
> **`GET /docs/catalog-import`** — products, categories, attributes, variants and
> groupings, photos, publication and rollback. It also has the decision table "variants inside
> a product ↔ separate linked products" and the field pitfalls (currency,
> main and additional categories, triple flags, pagination).

**Decide your path before reading.**

| You have | Read | Skip |
|---|---|---|
| **a ready folder or site** — landing, generator export, React/Vite build, Figma/Webflow/Tilda export | §2a "Ready folder → page", §2b "Layout = folder", area `GET /docs/transfer`, skill `vizen-transfer` | §5, §7, §8a, §11.2 — they cover the boxed level-1 html block and do not apply to a level-3 folder |
| **a level-1 html insert** inside a page built from platform widgets | §2–§8a, §11.2, §17 | — |
| **the same markup on MANY pages with different data** | §2c "Component with parameters", area `GET /docs/components` | — |
| forms and leads | [/docs/forms](/docs/forms) | — |

**The gist in one paragraph (level-1 html block).** You write plain HTML+CSS,
put it as a file into storage, wrap it in an "HTML document", insert the document
as a section into a block and bind the block to a page. Your HTML lands in the
page's SERVER html (SEO works) and renders in isolation: your styles will not
break the site, and the site's styles will not break you. On level 1 the server strips scripts —
not a bug, it is the contract (need JS — level 2, see §8; **a ready page
with scripts — level 3, §2a: real DOM, scripts run, no isolation**).

---

## 0. Base, token, prefixes

- **Base:** `https://api.vizen.shop`.
- **Token (PAT):** `Authorization: Bearer vz_pat_…` on every request — including
  public endpoints (storefront resolve, reading a page by slug): otherwise you will not catch a 403
  where an anonymous caller gets 200.
  Issued in the shop admin → `/api-access`, shown once.
- **First call — the key passport:** `GET /v1/account/token`. Returns the contour
  (`writes_to_live` — whether edits go straight to the live site), the shop with its
  currency and storefront address, `capabilities` (what the key can really do) and
  `warnings` — pass these to the owner in full. Take the storefront address from there
  (`store.storefront_url`); do not invent it.
- **Required scopes:** `catalog:read` + `catalog:write` + `storage:write`.
  Building a LEAD FORM (request/consultation/subscription) — you also need
  `forms:read`/`forms:write`, and `leads:read` to read the leads themselves;
  the whole scenario is in the separate area [/docs/forms](/docs/forms).
- ⚠️ **`catalog:read` decides not "whether you get in" but WHAT you see.** Without it the key
  works on public endpoints but returns exactly what a browser without a key gets:
  published items only, no drafts or hidden categories, always PROD —
  a dev key's draft will not show either. Such a key gets your own unpublished page
  as `404 …_NOT_FOUND`: not a permission refusal but the public
  projection. You can check your work BEFORE publication only with `catalog:read`.
  If you write the catalogue (`catalog:write`), take `catalog:read` too, or you cannot
  re-read what you created.
- ⚠️ **Prefixes are not uniform:** catalogue (`/products`, `/categories`,
  `/content-blocks`, `/html-documents`) — **without** `/v1`; storage and organisation
  (`/v1/storages/files`, `/v1/storefronts/resolve`) — **with** `/v1`.
- Full machine-readable schema: `GET /compiled.swagger.json`.

Key check: `GET /v1/account/token` → scopes, `company_id`, expiry. This is the
only reliable way: public endpoints answer 200 even without `catalog:read`
(with the public projection), so "200 on `/categories`" proves nothing.
`401` — the key is wrong or expired; on private endpoints a missing scope shows
explicitly: `403 PAT_SCOPE_MISSING (required_scope: …)`.

### ⚠️ `id` types in responses differ — do not be surprised

| Entity | What `result.id` returns |
|---|---|
| html document (`POST /html-documents`) | **string**: `"12"` |
| content block (`POST /content-blocks`) | **string**: `"139"` |
| category/page (`POST /categories`) | **number**: `45` |
| product (`GET /products/{id}`) | number |

The reason is historical (int64 is serialised to JSON as a string). In requests you may
send a number: the `/{id}` path wins over the body. When comparing ids, cast to string.

## 1. Read the shop first (otherwise the page will be made up)

| What you need | Request |
|---|---|
| Shop, currency, logo, language, contacts | `GET /v1/storefronts/resolve?slug={shop}` (public) |
| Tree of categories and pages | `GET /categories?company_id={id}` — ONE mixed list: `type:"product"` (catalogue category), `type:"page"` (page), `type:"news"` (news section); filter by `type` |
| Products (prices, slugs, images) | `GET /products?filter.company_id={id}&page.number=1&page.limit=24` |
| Articles/news | `GET /articles?company_id={id}&page.number=1&page.limit=10` (⚠️ `page.number` is required, ≥ 1) |
| Your html documents | `GET /html-documents` |
| What is already on the page | `GET /categories/by-slug/{slug}?company_id={id}` |

⚠️ **Product filters take the `filter.` prefix, and this matters.** A parameter
without the prefix is SWALLOWED SILENTLY: `GET /products?category_id=168` returns 200 and
the shop's ENTIRE catalogue, not the section. There is no error — just the wrong data,
and the wrong page gets built from it.

| Filter | What it does |
|---|---|
| `filter.category_id` | products of a section (the storefront's main filter) |
| `filter.ids` | several products by id at once |
| `filter.query` | search by name |
| `filter.is_published` | published only |
| `filter.min_price` / `filter.max_price` | price range |
| `page.number` / `page.limit` | pages; `sort.field` / `sort.order` — order |

Full list — `GET /compiled.swagger.json`. General rule: **check that the
response changed after adding a filter.** The same `total` before and after means
the parameter was not understood.

Link to REAL slugs and products: a product URL is the slug chain of its main
category + the product slug (`/kombo-nabory/kombo`); a page URL is the slug chain of
the page category (`/m1-smoke`).
⚠️ A product's `seo.slug` may be EMPTY — then the storefront addresses it by id:
`/{category-slug}/{id}`. Rule: `seo.slug || String(id)`, or you get
broken links.

### ⚠️ Response envelopes differ — look carefully

| Endpoint | Response shape |
|---|---|
| `GET /categories` | `{"result": [ … ]}` — the array directly in `result` |
| `GET /products` | `{"result": {"items": […], "total": N, "currentPage": N}}` |
| `GET /articles` | `{"items": […], "total": N, "current_page": N}` — NO `result` |
| `GET /products/{id}`, `/categories/by-slug/…` | `{"result": {…}}` + siblings (`content_blocks`, `categories`, …) |

One more mismatch: in the product LIST the slug is flat (`item.slug`), while in the
DETAIL it is inside seo (`result.seo.slug`). The addressing rule is the same: the slug or, if
it is empty, the `id`.

### The whole page JSON in one request

To build a unique product/category/news page, read it whole —
the response carries both the entity and its blocks:

```bash
GET /categories/by-slug/{slug}?company_id={id}   # page or category
GET /products/by-slug/{slug}?company_id={id}     # product (+categories, +reviews, +variants)
GET /articles/by-slug/{slug}?company_id={id}     # article (+related)
```

What is inside and how to use it:

| Response key | What it gives for generation |
|---|---|
| `result` | name, description, price, slug, SEO fields, images (`preview` + `galleries[]` — attached galleries with items; a variant's set refers to a gallery via `variants_info.variants[].gallery_id`) |
| `content_blocks[]` | ALL page blocks: `sections[].payload.kind` — block kind, `props` — its settings. Take texts, images and links from there to reuse in your markup |
| `content_blocks[]` with `name:"__page:top"` | the zone's table of contents: `refs` (block order) and `page` (flags — whether header/footer/breadcrumbs are hidden) |
| `categories` (on a product) | the chain for breadcrumbs and links |
| `attribute_sections`, `reviews_summary`, `variants_info`, `group_info` | attributes, rating, variants — material for the card |

Rule: **read the page first, then generate.** That way your block
links to the shop's real products and images, not made-up ones, and does not
repeat what is already on the page.

What to take from a product for a card on your page (live response example):

```jsonc
"name":  "Кровать двухъярусная лофт Волстрит 120x90",
"price": { "price": 61192, "old_price": 71991, "currency": "RUB",
           "promotion_name": "Кроватная распродажа −15%" },   // WHOLE roubles
"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"],                                          // references to shop galleries
"galleries": [                                                  // contents; may be empty
  { "id": "31", "name": "Синие", "source": "images", "canvas_id": "0",
    "items": [ { "item_key": "8231…", "kind": "image",           // kind: image|video|audio|html
                 "file": { "id": "…", "url": "/files/…" }, "caption": "" } ] }
]
```

Print the price as is (whole currency units, not kopecks); take the currency from
`price.currency` or `storefront.currency`. Take the product image from
`preview.url` or from `galleries[].items[].file.url` (items with
`kind: "image"`) — that is reuse (§3). A gallery is a separate
shop resource (`/galleries`); the same one can sit on several
products; LISTS (`GET /products`) do not carry it, only `preview`.

List prices and detail-card prices of combos come from one shared server-side
composition calculation: the old mismatch "list with the promo, card without it" is fixed.
Use the ready prices; do not call `GET /products/{id}` for every tile and do not
recompute combos on the client. Contract of the live feed and composition —
`GET /docs/own-markup` §2, `GET /docs/catalogue` §4.1.

Replacing a section's standard grid with your own markup via PAT: the only recipe
with a snapshot and rollback is `GET /docs/components` §4.8. Template language and
level 1 limits — `GET /docs/own-markup` §4; selected image fields and `sizes` — §6.

## 2. Create an HTML document

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

`level`: **3** — your own markup in the page's real DOM (scripts execute,
`position: fixed` works, content is indexed; owner/admin role, the server
flag is on in prod) — **the level of any ready page or folder** (§2a;
`transfer.mjs` creates the level-3 document itself); **1** — an html insert inside
a page built from widgets (sanitised, Shadow DOM, no scripts; §5–§8a);
**2** — a script in an iframe sandbox without SEO (§8). Empty = 1 — the insert default,
not the transfer default. If the server refuses level 3, tell the owner; do not fall back to level 1:
it is a different model.

## 2a. Ready folder → site page (HTML project, level 3)

### THE SINGLE BUILD RULE (owner, 2026-09-03) — read first

The page is built like an ordinary site in a folder and transferred with **one command**;
no rebuilds, no manual file uploads, no path rewriting.

1. **Widget = folder.** `index.html` at the root + `styles.css` + `app.js` +
   `assets/…` in subfolders. All paths are relative — **from the folder root**. On
   the site the files are served from the widget root `/_html/{doc}/{release}/…` with the same
   nesting; the site page takes no part in addressing. Junk (mock-ups,
   previews, README) goes into `.vizenignore`.
2. **`index.html` is plain markup:** markup + `<link>/<style>/<script>`,
   **without** `<!doctype>/<html>/<head>/<body>`. Got a whole page —
   no problem, the platform strips the wrapper itself on publication.
3. **A script that builds paths itself** (`img.src = …`, `fetch(…)`) takes
   the widget root — each project has its own:
   `const base = document.currentScript?.closest('.vz-body')?.dataset.vzBase ?? window.VZ_ASSET_BASE ?? ''`
   (inline script); `img.src = base + 'assets/x.jpg'`.
   Locally, from the folder, `base` is empty; on the site it is the widget root. An external or
   `defer` script no longer has `document.currentScript` — there use
   `document.querySelector('.vz-body-<id>')?.dataset.vzBase` or
   `window.VZ_ASSETS[<id>]`, where `<id>` is the document id from the API response.
4. **Locally** open via `npx serve .` (not `file://`). It shows
   the bare folder — without the site header and footer, without its cascade and without `vz-`
   substitutions; the exact preview is publication with a dev key (`<slug>--dev`).
   What changes on the site — the subsection "Storefront cascade and runtime" below.
5. **Transfer:** `node backend-3D/tools/html-transfer/transfer.mjs ./folder --page <slug>`
   (Node 18+, key in `VIZEN_TOKEN`; stand — `--api http://127.0.0.1:3970`)
   The first run creates a level-3 document, a release and a block on the page; every
   next run uploads **only the changed files** and updates the page right away.
   ZIP in one request `POST /html-documents/{id}/releases/zip?publish=1` —
   release only: create the document beforehand (`POST /html-documents {level:3}`),
   mount the block on the page yourself ("The same path by hand" below).
   Validator errors — fix and run again; rollback — `--rollback`.
6. **Demo template** = the same folder. A new page from the template — copy the folder and
   `--page another-slug`; a version — a release; an edit — file + rerun the command.
7. **Check after transfer** (mandatory): open the `url` from the response
   in a browser (headless counts as a browser), take desktop + mobile
   screenshots, look at the console: no JS errors, no 404s on files; compare with
   the local version. A mismatch — fix the folder and repeat step 5.
8. **Do not:** use absolute URLs instead of relative, cut the page into
   blocks, use iframe/shadow "for isolation", upload files one by one via the
   storage API, put a second `.html` page into the release.

Chat workflow: read the key (`/v1/account/token`) → built the page
locally in a folder → one transfer command → opened the address, screenshot, console →
report to the owner with a link.

If the page is already laid out (landing, neural-network export) —
**do not rewrite paths and do not cut the HTML into blocks**: the folder is transferred as is.
Platform rule: "`<head>` is removed, all CSS/JS bindings stay" — the folder
goes into the page's real DOM, with no Shadow DOM and no iframe.

**Boundary: one widget = one html page** (`index.html`). Extra `.html`
files in the release — warning `page.extra`; the gateway does not serve them. A multi-page site is
several widgets on several site pages.

### Intake standard: there is one wrapper — the site's

The site already has a page with a header and footer; your markup goes into it
**with no iframe and no shadow DOM**, scripts work, images open.
So the normal input is **plain markup** (format 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>
```

— without `<!doctype>`, `<html>`, `<head>`, `<body>`. Send
`index.html` exactly like this if you build a page for Vizen from scratch.

Format 1 is not "second class": on publication it also gets the wrapper
`<div class="vz-body vz-body-{doc}">…</div>` — the same as a whole page.
It is where your `body/html/:root` from CSS land (see below), and it
carries the widget's file root. Republishing does not add a second wrapper.

**A whole page** (format 2: has `<html><head><body>`) is also accepted —
on publication the platform converts it to format 1 itself:
- of `<head>` only `<link>`, `<style>`, `<script>`, `<noscript>` remain →
  `<div class="vz-head vz-head-{doc}" hidden>…</div>`;
- `<title>`, `<meta>`, `<base>` are dropped; `title` and `description`
  go into the site page's SEO (if its fields are empty);
- `<body class="dark" data-x="…">` → `<div class="vz-body vz-body-{doc} dark" data-x="…">…</div>`;
- your selectors `body { … }`, `html { … }`, `:root { … }` (in `<style>` and in
  `.css` files) are rewritten to `.vz-body-{doc}` — styles land on your
  wrapper, and two projects on one page do not overlap.

**Only** the clean result is stored: the converter writes it in place of the release's
`index.html`; the original is not kept; the document's `source_file_id`
points to this file, which is visible and editable in the admin ("Page code").
`{doc}` is the document id. An edit in the admin = a new release (the "Page code" window
saves via `POST …/releases {base:"active"}` → `PUT` → `publish`);
replacing a release file directly via `ReplaceFile` is forbidden — `FILE_IN_RELEASE`.
The project level is fixed (3); the "Block files" panel is hidden — files live in the
release.

### Folder rules (checked by the validator on publication)

1. `index.html` at the root is the widget's only page; other `.html` files in the
   release — warning `page.extra` (the gateway does not serve HTML).
2. **All paths are relative, from the folder root and inside it.** `../something` past
   the root — error `ref.outside`; a file that does not exist — `ref.missing`.
   String file paths in `.js` without `window.VZ_ASSET_BASE` —
   warning `js.assets` (on the site such a path resolves against the page
   URL, not the widget root). If you read the root via `dataset.vzBase`,
   keep `window.VZ_ASSET_BASE` as the fallback and there is no warning.
3. `<head>` is not needed: on publication only bindings remain
   (`link/style/script/noscript`), `title`/`meta description` go into the site
   page's SEO, the rest (`<base>`, `og:*`, `http-equiv`, `manifest`)
   is dropped (warning `head.stripped`).
4. External resources (Google fonts, CDN) — `https://` only; `http://` —
   error `ref.http`. Better to put fonts into the folder.
5. Prepare images at the needed size yourself: `/w/…` resizing does not apply
   to project files (they are served byte for byte). Recommended ≤ 400 KB and `width/height`
   on `<img>` — otherwise warnings `img.large` / `img.nosize`.
6. Limits: ≤ 500 files, HTML file ≤ 2 MB, any file ≤ 50 MB.
7. Not allowed: server executables (`php/py/sh/exe…`) — `file.forbidden`;
   `navigator.serviceWorker.register` — error `sw.register` (a worker at
   the root would intercept the whole shop, including payment).
8. Links to the site — an absolute path (`/catalog/x`) or `vz:page/<id>`;
   forms — `href="form:<id>"` (§13). A `<form>` without a handler —
   warning `form.nohandler`.
9. Do not put into the folder: mock-ups, `previews*/`, `originals/`, `README`, `qa.mjs`
   — use `.vizenignore` at the root (`.gitignore` syntax); hidden files
   (`.DS_Store`, `.git/`) are skipped silently.
10. One project = one site page. Locally open via an HTTP server
    (`npx serve .`), not `file://`.

### Paths on the site: widget = folder, one mode

A widget is a block inside a site page (header/footer/other blocks around it);
there is one wrapper — the site's; there are no other modes. All release files are served from the widget
root `/_html/{doc}/{release}/…` with the same nesting as in the folder:

- paths in markup and CSS (`src`, `href`, `srcset`, `url(…)`, `@import`)
  are rewritten to this root on output — change nothing in the folder;
- paths built by a script go through the widget root. `/content` provides it
  in two ways: the attribute `data-vz-base="/_html/{doc}/{rid}/"` on the wrapper
  `.vz-body-{doc}` and, as the first node,
  `<script>window.VZ_ASSET_BASE="…";(window.VZ_ASSETS=window.VZ_ASSETS||{})["{doc}"]="…"</script>`.
  The recommended way is to read it from **your own** wrapper:

  ```js
  // inline script inside the widget
  const base = document.currentScript?.closest('.vz-body')?.dataset.vzBase ?? window.VZ_ASSET_BASE ?? ''
  // external or defer script (currentScript is already null), 51 is the document id
  const base51 = document.querySelector('.vz-body-51')?.dataset.vzBase ?? window.VZ_ASSETS?.[51] ?? ''
  ```

  `window.VZ_ASSET_BASE` is for "when there is one widget on the page": two widgets
  overwrite each other's global, and the first one's deferred script reads the
  second one's root. Without the root a path resolves against the page URL and gives 404.

A level-3 block renders **without the platform box** (no `.vz-box`/padding/
radius); need the box — `props.html.boxed: true`. On transfer to a page,
old html blocks (including earlier iframe transfers) are removed — there is one project
per page. A dev key publishes into the draft: the page is visible at
`<slug>--dev.<zone>` under the owner's session (the storefront calls `/content` with
contour headers); a prod key publishes to the live site.

### Storefront cascade and runtime — what changes on the site compared to `npx serve`

A level-3 widget is the page's real DOM; there is no style isolation (by design:
the author owns the page). Therefore:

- **The storefront cascade applies to your markup.** The storefront loads 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}`.
  Your CSS outside layers wins at equal specificity — everything set
  explicitly survives; everything left to browser defaults (heading sizes,
  list markers, link underline, button look) is reset. Put your own
  base.css under the project's root class. The page font is `ui-sans-serif,
  system-ui` 16 px; your `body{font-family}` lands only on `.vz-body-{doc}`.
- **`body`, `html`, `:root`** in `<style>` and in the release's `.css` are rewritten to
  `.vz-body-{doc}` (compound `body.dark` is not). `body{overflow:hidden}` from
  a modal script will not lock page scroll — lock your own root.
- **Scripts execute when the page is parsed and again after following a
  link within the site** (the platform recreates the widget's `<script>` nodes). Write
  code that works "right now", and re-initialise on the
  `vz:navigate` event on `document` (`detail.path`); `DOMContentLoaded` alone is not enough —
  after a click it will not fire again.
- **Restricted zones** `/cart`, `/checkout`, `/account`, `/wishlist`, `/orders`,
  `/deals`, `/dashboard`: the header markup is there, scripts do not execute — and
  the widget's relative `<link rel="stylesheet">` is cut out with them
  (measured on the stand 2026-09-12: header on `/cart` without its `header.css`).
  Inline `<style>` stays; the layout folder's CSS is linked in `<head>` everywhere.
  So: header styles go into the layout folder or inline.
- **Geometry and visibility:** matrix `/docs/own-markup` §7.2. Level 3 by
  itself does not guarantee fixed relative to the viewport; account for ancestors and the browser. Unboxed HTML
  changes main's query container and the visibility ranges of neighbouring blocks.

- **Release files are served byte for byte** (`/w/` does not apply to them) — prepare
  image sizes yourself; the server rewrites shop images in `<img src>` to
  resized versions itself.
- **Shared CSS:** the layout folder (§2b). The single CSS matrix by surface,
  Shadow DOM limits and guarded pages — `/docs/own-markup` §7.2.

The full contract with the table "what moves 1:1 / is rebound / does not move" and
notes on React/Vite, Tailwind and exports — `GET /docs/transfer`.

### The shortest path — the script

The script lives in the `backend-3D` repo: `tools/html-transfer/transfer.mjs`
(Node 18+, no dependencies; README next to it). Needs scopes `catalog:write` +
`storage:write` (+ `catalog:read` for `--list`) and the owner/admin role in the shop.
The page `--page <slug>` must **already exist** (`type=page`; slug —
from `GET /categories`, create — `POST /categories`).

```bash
VIZEN_TOKEN=vz_pat_… node backend-3D/tools/html-transfer/transfer.mjs ./my-site --page <page-slug>
# rerun = new release; ONLY changed files are uploaded (dedup by sha256;
# index.html is always uploaded — the converter changes it)
node transfer.mjs --doc <id> --list      # releases
node transfer.mjs --doc <id> --rollback  # roll back to the previous one (pointer only, no new files)
node transfer.mjs ./my-site --page <slug> --api http://127.0.0.1:3970   # stand
```

The response has `url` (the page address; for a dev key `<slug>--dev.<zone>`),
`path`, `embed_prefix` (= the widget's file root), `seo`.

### The same path by hand (API)

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

# 2) release manifest: the server answers WHAT to upload (known hashes are reused)
POST /html-documents/{id}/releases
{ "base": "active",                     # "active" (default) | "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) bytes — only for upload[] (PUT without Authorization)
PUT <upload_url>

# 4) publication: confirms uploads, validates, converts a whole page
#    into plain markup, atomically makes the release active
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 }  — fix the errors, build a new release with base:<rid>
# → 409 FILES_PENDING { pending }   — upload the remaining files and retry
POST /html-documents/{id}/releases/{rid}/validate   # the same without publishing

# 5) mounting on the page: a level-3 html block (the type=page page must exist)
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          # list, active one, page path
POST /html-documents/{id}/rollback          # { "release_id"? } — previous published one; pointer only
```

**ZIP without the script** (the full path if the `backend-3D` repo is not at hand):

```bash
# 1) level-3 document
POST /html-documents  { "item": { "name": "Лендинг курса", "level": 3 } }   # → id
# 2) the folder archive in one request — the server computes hashes, dedups, validates and publishes itself
curl -X POST "https://api.vizen.shop/html-documents/{id}/releases/zip?publish=1" \
     -H "Authorization: Bearer $VIZEN_TOKEN" -F file=@site.zip     # or Content-Type: application/zip + --data-binary
# a root folder inside the archive (site/index.html) is unwrapped automatically; archive limit 60 MB
# 3) a kind:"html" block with props.html.{html_document_id, level:3} on the page — step 5 above
```

A dev key publishes the release into the shop draft (the page is visible at
`<slug>--dev.…`), a prod key to the live site. Rollback and activation switch
only the pointer — they create no new files. A document over 2 MB on output —
`FailedPrecondition HTML_DOCUMENT_TOO_LARGE`.

### One mechanism — the release. Editing a published page without re-uploading

All inputs (script, ZIP, manual manifest, the admin's "Page code" window)
converge on **one mechanism — the release**. The owner asks "change the text here" —
the chat must be able to fix an already published page without re-uploading the
project:

```bash
# 0) document id: by name from the company list …
GET /html-documents                                   # → result[] {id, name, level, …}
#    … or from the page block
GET /categories/by-slug/{slug}?company_id=<N>         # → content_blocks[].sections[].payload.props.html.html_document_id

# 1) current plain markup (index.html of the active release, already without head/body)
GET /html-documents/{id}                              # → result.source.url — download the text

# 2) fix the markup locally

# 3) partial release: only index.html, the rest is inherited from the active one
POST /html-documents/{id}/releases
{ "base": "active", "files": [ { "path": "index.html", "sha256": "<hex64>", "size": N } ], "note": "текст в hero" }
PUT <upload_url>                                      # index.html bytes
POST /html-documents/{id}/releases/{rid}/publish      # the page is updated; rollback — POST …/rollback
```

To replace/add one file (an image) — the same call with that path in
`files`; to delete — `"delete": ["assets/old.png"]`. The admin's "Page code" window
makes exactly this partial release. `index.html` is **never
deduplicated** (the converter changes it in the release) — this is normal, not an error;
the document's `source_file_id` always points to the active release's `index.html`.

## 2b. Site layout = folder (shared CSS, fonts and code on every page)

**Where shared CSS lives: not in the header but in the layout.** Styles, fonts and scripts
put inside the header's html widget are seen only by the header itself: other
widgets, the page body and the editor frame live outside its isolation. Everything that
belongs to the SITE rather than one block goes into the LAYOUT folder — it applies to
every page of the shop.

A **layout** is a container group `group_type:"theme"` ("Layout" in the UI). There can
be several: a section may have its own design. Which layout a page gets is decided by
the `theme` slot along the ladder "page → its sections → shop → the earliest
layout". Inside the layout you choose header/strip/body/footer
(`PUT /design/bindings/theme/{id}`, in detail — `GET /docs/chrome` §3.6), while
files and code are this section.

### How to find the layout id

```bash
# the block list has no server-side filter — filter the response yourself
curl -s -H "Authorization: Bearer $VZ" $API/content-blocks \
  | jq '[.result[] | select(.group_type=="theme") | {id, name}] | sort_by(.id)'
# "Main" = the earliest one (minimum id) — the default resolve uses the same rule
```

### What goes into the folder

The folder is organised like a widget's (§2a): `index.html` at the root, files in subfolders,
relative paths, releases · rollback · dev contour — the same single mechanism. What changes
is the meaning of `index.html`: for a widget it is content, for a layout it is the **site skeleton**.

| In the folder | Where it goes |
|---|---|
| the skeleton's `<head>` | `meta`/`link`/`style` nodes become real tags in the `<head>` of **every** page; `script`/`noscript` travel as raw HTML |
| `class` on `<body>` | onto the page root (next to the scope class) |
| `<body>` contents | code before `</body>` on every page |
| `<title>`, `<base>`, `<meta http-equiv>`, `<link rel=manifest>` | cut out on publication: the title belongs to the page, the rest to the platform |
| any `*.css` | selectors `html`, `body`, `:root` are scoped to `.vz-theme-{doc}` — the page ROOT class |
| `icon.png` (`.jpg`/`.jpeg`/`.webp`) | `icon-32.png`, `icon-180.png`, `icon-192.png`, `icon-512.png` and `favicon.ico` are generated; your own files with the same names are not overwritten, `icon.svg` is taken as is |
| other files | served from the root `/_html/{doc}/{rid}/…`; scripts take the root from `window.VZ_THEME_BASE` |

**You may write `body { … }` and `:root { … }`** — they land on the page root, and
that is the standard way to set the site's font, background and variables. Compound selectors
(`body.dark`, `body[data-x]`) are deliberately NOT rewritten: your script puts the class on the real
`<body>`, and the rule must stay there too.

The skeleton is parsed **once on publication** and stored assembled in the
release row — the storefront does not parse HTML. SEO is not extracted from the skeleton: one `<title>`
for the whole site would be a direct loss in search results.

### Layout folder endpoints

Scopes and role are the same as for widget releases: `catalog:write` + `storage:write`
(+ `catalog:read` for reading), owner/admin role in the shop.

| Method | Path | What it does |
|---|---|---|
| `GET` | `/themes/{id}/site` | folder state: `{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}`. No folder → `{document_id:0, files:[]}`; **GET creates nothing** |
| `POST` | `/themes/{id}/site/releases` | draft release from the manifest `{base, files[{path,sha256,size}], delete[], note}` → `{release_id, upload[], reused[], inherited[]}` |
| `POST` | `/themes/{id}/site/releases/zip` | release from ZIP (`?publish=1`, `?base=`) |
| `POST` | `/themes/{id}/site/releases/{rid}/publish` | validator → skeleton cleanup → favicons → skeleton parsed into the release row → pointer switch. Response `{release_id, base, scope_class, icons_generated[], report}`; no `url`/`path` — a layout does not sit on one page |
| `POST` | `/themes/{id}/site/releases/{rid}/validate` | the same without publishing |
| `GET` | `/themes/{id}/site/releases`, `/themes/{id}/site/releases/{rid}` | release list with the active one / one release with its file map |
| `POST` | `/themes/{id}/site/rollback` | rollback `{release_id?}` — by default the previous published one; pointer only |

```bash
# ZIP in one command: folder → release → publication
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}'
```

With the script — the same in one command (`--theme` and `--page` never go together: a layout
is not mounted on a 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
```

### Bind the layout and style system pages

```bash
# the whole site
PUT /design/bindings/shop/0          { "slots": { "theme": 118 } }
# one section (and everything under it)
PUT /design/bindings/category/648    { "slots": { "theme": 118 } }
# system page: 1 = cart (registry below), slots theme|header|footer only
PUT /design/bindings/system/1        { "slots": { "theme": 118, "header": 1975 } }
# what actually applies
GET /design/system/cart?company_id=<N>    # public: { "design": { "page": { header, footer, theme_id, … } } }
```

System page registry (number = `resource_id`; numbers are never reused):
`cart=1 · checkout=2 · account=3 · orders=4 · wishlist=5 · search=6 · deals=7 ·
dashboard=8 · coupons=9 · notfound=10 · thankyou=11 · compare=12`. Without their own
bindings they inherit the shop's layout and chrome.

### Your code on the page: `vz:navigate` and restricted zones

- navigation within the site happens without a full reload. After every navigation **and
  after the first load** `document` receives
  `CustomEvent('vz:navigate', { detail: { path } })` — re-initialise
  your code on it. Do not rely on `DOMContentLoaded`: after a click the document is already
  loaded;
- navigating to a page with a DIFFERENT layout = full reload (styles of two
  layouts do not mix);
- **restricted zones** — `/cart`, `/checkout`, `/account`, `/wishlist`, `/orders`,
  `/deals`, `/dashboard` (and everything nested). Neither layout scripts
  (`head_html`/`foot_html`, `window.VZ_THEME_BASE`) nor level-3
  html markup scripts execute there: this is money and personal data. Styles, classes and
  markup remain — the cart looks like part of your site but does not execute third-party
  code. You cannot rely on a script in the cart.

### Stable site file addresses: `/_site/<path>`

`/_html/{doc}/{rid}/…` is versioned on purpose: a release is immutable, so such an
address can be cached forever. That is exactly what makes it unfit where a link
lives LONGER than the release — an email to a customer, an email signature, a business listing,
someone else's site: `/_html/51/318/logo.svg` written there becomes 404 on the very next
folder publication.

```
GET /_site/logo.svg          # the same file of the ACTIVE layout, without a release number
GET /_site/favicon.ico
```

Resolution is the same as for the site header: the shop's `theme` slot → the earliest layout →
its folder → the active release. There are three differences from `/_html/`, all following from
the missing release number:

1. cache `public, max-age=300` + ETag instead of immutable (a publication must
   reach the customer on its own);
2. **the tenant header is required** — addressing starts from the host; without it
   the "active layout" is undefined: a direct API request gives 404;
3. release HTML pages are not served (as in `/_html/`).

The CSS scope is the same — `.vz-theme-{doc}`. The rule is simple: inside the site link to
`/_html/{doc}/{rid}/…`, give `/_site/…` to the outside world.

### Layout for an article

An article is a design resource just like a product or a section:

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

Ladder: `article → its news section and the section's ancestors → LAYOUT → shop → default`.
The inheritance branch is `category`, not `category_children`: an article sits in a news section
like a subsection in a section, so a binding made ON the news section itself reaches its
articles. To read the result: `GET /articles/by-slug/{slug}` responds with the
`design` field (the admin `GET /articles/{id}` does not resolve design and does not
carry it). While an article has no bindings of its own, the result is the same as before — sites
notice nothing.

⚠️ `body_article` is a **LAYOUT** slot, not an article slot: which body view
articles get is decided by the level ABOVE the article. `PUT /design/bindings/article/{id}` with
the `body_article` slot responds `DESIGN_SLOT_NOT_FOR_RESOURCE`.

### Layout folder boundaries

- limits are the same as a widget's: ≤ 500 files, HTML ≤ 2 MB, any file ≤ 50 MB,
  ZIP ≤ 60 MB. A skeleton over 64 KB — warning `theme.index_large` (it
  lands on EVERY page);
- the validator is the same, minus rules that make no sense for the frame
  (`head.stripped`, `page.noindex`, `img.nosize`, `file.unused` on icons);
  `window.VZ_THEME_BASE` is accepted as the file root on a par with `VZ_ASSET_BASE`;
- an unreadable skeleton is not published: `422 RELEASE_INVALID` with code `html.parse`
  (a broken skeleton would land on the whole site at once);
- the folder's service document cannot be edited via the `/html-documents/*` endpoints
  (`FailedPrecondition THEME_DOCUMENT_PROTECTED`), is not visible in the document list,
  and `/content` does not serve it;
- a non-layout, foreign or deleted id in `{id}` all give `404 THEME_NOT_FOUND`.

## 2c. Component with parameters — one markup on many pages

The question to ask BEFORE writing markup: **is this markup needed
more than once?** If the answer is "on five pages, each with its own title and
image", an html widget is the wrong object: the code would be copied five times, and
the sixth edit becomes six edits. This case is called a **component**.

A component is the same folder-document and the same releases (§2a), plus one file:
`component.json` next to `index.html` with the parameter schema, while the markup holds
`{{key}}`. Values live IN THE INSERTION, not in the code: the seller fills them in a form,
and a new folder release updates all insertions at once.

```bash
# folder → kind=component document → release → publication, with the schema in the response
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 } } } } }
```

What to know right away; the rest is in the area `GET /docs/components`:

- substitution is **typed and server-side**: `text` is escaped, `html` goes
  through the level-1 sanitiser, `link`/`image` — through a scheme whitelist; raw `{{ }}`
  never appears on the storefront, and a key not in the schema prints nothing;
- the schema mirrors a Shopify theme section (`params` = `settings`); there are eight types:
  `text`, `html`, `image`, `link`, `color`, `number`, `bool`, `select`.
  `products`/`category` are NOT supported yet: a parameter is a value, not a
  catalogue query;
- TypeScript is allowed in the folder: `app.ts` is compiled into a sibling `app.js` on
  release publication; no bundler needed. On level 1 the sanitiser strips the script anyway —
  live code needs level 3 (`--level 3`);
- the schema is NOT read from `component.json` through the gateway: parsed, it arrives as the
  `manifest` field on the document (base64) and on each release (as an object), next to
  `active_release_id` — the honest check "can the component be placed at all".

## 3. Images: upload and reuse

**Upload is always 3 steps** (the same for HTML and for images):

```bash
# 1) request
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 may be RELATIVE (/_upload/…) — prepend the API host

# 2) the bytes themselves
PUT <upload_url>            # body = the file, no Authorization

# 3) confirmation — by upload.id (NOT result.id)
POST /v1/storages/files/{upload_id}/done   { }
```

`file_type` is a **category**, NOT a MIME type: `image | video | audio | model | file`
(send a MIME type and the server normalises it to a category itself). A file is attached to a product/document
by `result.id`; `/done` takes `upload.id`.

File sections: `html-source` — the page HTML itself, `html-asset` — its images
(both are hidden from the shop's shared media library, so junk does not pile up).

**The image is ALREADY on the internet — let the server fetch it, in one request:**

```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 }
```

No PUT and no `/done`: the file is already saved, converted and resized;
`public_path` is final. Public `http(s)` addresses only — a link into the
internal network (`localhost`, `127.*`, `10.*`, `192.168.*`, `169.254.*`, `::1`)
gives `400 URL_NOT_ALLOWED`, and the same rule applies to every redirect
(at most three, timeout 30 s). Failed to download or not an image →
`400 URL_FETCH_FAILED`; over 10 MB → `400 FILE_TOO_LARGE`. The format is taken from
the bytes themselves (`jpeg|png|gif|webp|bmp|tiff`), not from the response `Content-Type` —
HTML/3D/archives keep the same 3 steps above.

**Image rules:**

1. **The URL comes from the API response** (`result.url` / `file.url`); never make it up.
2. **`src` holds a relative path** (`/files/sunset/view/content/…`): the storefront
   serves files from its own domain.
3. **Resizing** — the prefix `/w/{width}` or `/w/{width}/webp` before the path:
   `/w/1024/webp/files/sunset/view/content/05/d1/…webp`.
   Ladder of pre-generated widths: 32 | 64 | 128 | 320 | 640 | 1024 | 1600 |
   2048 | 2560 — take the step ≥ 2× the CSS size (hero 1600, content 1024,
   cards 320/640); the same for `srcset`. Quality: default per step,
   override with `?q=40..95`. SVGs are not resized — insert them as is.
4. **Reuse:** an already uploaded file (including a product photo from the catalogue)
   is inserted by its URL — **do not upload it again**. The same `file_id`
   can be registered in several documents.
5. **Register the document's assets** — this protects the file from deletion (`FILE_IN_USE`):

```bash
PUT /html-documents/{id}
{ "id": 10, "item": { "assets": [ { "file_id": "<uuid>", "path": "img/hero.webp" } ] } }
# non-empty assets = replace-set (list ALL assets at once)
```

`path` here is a **human-readable label** for bookkeeping ("what this file is in the
document"), NOT an alias: rendering does not rewrite `src` by it. In HTML always
put the real URL from the API response (rule 1). Real relative paths
(`assets/hero.webp` as in the folder) come with an **HTML project release** (§2a) — there the file's
path is its address.

### 3.1. Rules for images from the shop STORAGE (level-1 html block and media library)

> Does not apply to HTML project release files (`assets/…` in the folder, §2a): they
> are served byte for byte, `/w/` does not apply to them — prepare the size yourself
> (recommended ≤ 400 KB, `width`/`height` on `<img>`). Product images in
> your own markup arrive already resized (`{{ p.preview }}`).

You upload the ORIGINAL (it is needed for future widths), but in HTML you **never
link to the original**. Always `/w/{width}/webp{path}`.

**The one-line rule:** width = the nearest step ≥ 2× the pixels the image
actually occupies on screen.

Ladder of pre-generated widths: **32 · 64 · 128 · 320 · 640 · 1024 · 1600 · 2048 · 2560**
(other widths work too but are computed on the fly — the first visitor waits).

| On the page | Occupies | Use | With `srcset` |
|---|---|---|---|
| Full-width hero | ~1200 px | `1024` | `1024 1x, 1600 2x` |
| Image in a text column | ~600-800 px | `640` | `640 1x, 1024 2x` |
| Card in a 3-4 column grid | ~250-300 px | `320` | `320 1x, 640 2x` |
| Small tile, logo, icon | 40-80 px | `64` | `64 1x, 128 2x` |
| Review avatar | 48 px | `64` | `64 1x, 128 2x` |

**Skeleton of every image in a landing:**

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

- `srcset` with a 1x/2x pair — otherwise the image is blurry on retina, and without it twice
  as much as needed is loaded;
- `width`/`height` — so the page does not jump while loading;
- `loading="lazy"` — on all images **except the first one on screen** (the hero loads immediately,
  give it `fetchpriority="high"`);
- `alt` — in plain human language; it is both SEO and accessibility.

**A background image in CSS** (`background-image`) cannot use `srcset` — there simply
take the step one above the slot: a ~1200-wide block → `/w/1600`.

**What you must NOT do:**

| Do not | Why |
|---|---|
| `src="https://storage.yandexcloud.net/..."` or `/files/...` | that is the original; it may weigh 10 MB for a 300 px tile |
| one width for all slots (`/w/1600` everywhere) | a hero and a thumbnail differ 20× in weight |
| a width "with margin, just in case" | every extra ladder step is ×2-4 in weight |
| `?q=` without need | quality is already tuned per step |
| SVG via `/w/` | the vector gets rasterised and loses its point — insert it as is |

**Self-check before handing over** (run it, mandatory):

```bash
# 1) the markup has NO direct links to the original — must be 0
curl -s https://{slug}.vizen.shop/{page} | grep -c 'storage.yandexcloud\|"/files/'

# 2) see which widths are actually requested
curl -s https://{slug}.vizen.shop/{page} | grep -oE '/w/[0-9]+' | sort | uniq -c

# 3) weight of the heaviest image on the page (the hero) — normal is up to ~200 KB
curl -s -o /dev/null -w '%{size_download}\n' https://{slug}.vizen.shop/w/1024/webp/...
```

If the first command is not zero, the page is not ready.

## 4. Upload the HTML and bind it to the document (level 1/2; a level-3 folder skips this step — §2a)

```bash
# 3 upload steps with section=html-source, file_type=file (a category, not MIME)
POST /v1/storages/files
{ "section": "html-source", "file_type": "file", "original_name": "index.html" }
PUT <upload_url>                      # body = your HTML
POST /v1/storages/files/{upload_id}/done  { }

# binding the file to the document
PUT /html-documents/{id}
{ "id": 10, "item": { "source_file_id": "<file_id>" } }
```

Editing the page = a new file + another `PUT` of the document (the source is the truth).
An HTML project with releases (§2a) does not need this step: `index.html` is part of the
release, and an edit is a new release.

## 5. Make a block and configure its wrapper (boxed level-1 html block)

> ⚠️ A transferred folder (level 3) has no box until
> `props.html.boxed: true` is set: wrapper settings do not apply to it; geometry
> is set by your CSS (`/docs/transfer` §7). This section is about a boxed block.

```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, … } }
```

### How many documents per page — the rule

**You decide.** One document for the whole page is normal: a transferred folder
(§2a) always works this way; editing a part = a partial release of one file. Separate
section documents (hero, benefits, reviews, CTA) are needed only when the owner
wants to rearrange parts in the admin, hide one on mobile or interleave your
sections with ready platform blocks (galleries, product cards, forms); the cost is
a release of its own for each part.

### Wrapper settings reference (exactly what the admin panel has)

| Panel | Key in `props` | Values |
|---|---|---|
| Block width | `blockWidth` | `"content"` (default, site column) / `"full"` (to the screen edge) |
| Content width (visible with `full`) | `contentWidth` | `"content"` (back into the column) / `"full"` |
| Margin ↑ / ↓ | `marginTop` / `marginBottom` | number, px |
| Side margin | `marginX` | number, px (None = 0; S/M/L — panel presets) |
| Corner radius | `blockRadius` | number, px. ⚠️ The STRIP itself defaults to 0 — 16 px is the system rounding of the block's INNER elements (class `.vz-radius`). Current values — `GET /docs/widgets`, section `wrapper` |
| Visibility on devices | `visibility` | `{ "min": 320, "max": 1024 }` — window width, px |
| Tablet layer (640–1023) | `tablet` | the same keys, per field |
| Mobile layer (< 640) | `mobile` | the same keys, per field |

Ready combinations:

```jsonc
// full-width hero, text in the site column
{ "blockWidth": "full", "contentWidth": "content" }

// narrow insert with air and radius
{ "blockWidth": "content", "marginX": 24, "blockRadius": 16,
  "marginTop": 32, "marginBottom": 32 }

// on mobile, drop side margins and radius
{ "blockWidth": "full", "marginX": 24,
  "mobile": { "marginX": 0, "blockRadius": 0 } }

// desktop-only block
{ "blockWidth": "full", "visibility": { "min": 1024 } }
```

**Boxed block:** start with `width:100%` and the wrapper settings; check for
unintended horizontal scroll. Fixed geometry, query containers and
side effects of unboxed HTML are described in `/docs/own-markup` §7.2.

### Ready platform block: "Category products" (`kind: "productListing"`)

For a ready filter and pagination you can place a platform block: a grid of
a category's products with a filter by its attributes and pagination, the same
component as on the category page. No data is stored in the payload — the storefront
fetches published products by `categoryId` itself. For your own structure and design
there are `products[]` and vz keys (§17, `/docs/own-markup` §2); mind the feed limits.

```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 of a `type='product'` category; required —
without it the block does not render) · `showFilter` (default true) · `limit` (4–48,
default 24) · `template` (oneOf `["default"]`; empty = shop default;
an unknown value reads as `default`). The schema is additive; a partial
object is allowed. Filter/pagination are INDEPENDENT per block: each has its own
URL parameter namespace `b<blockId>_f_*` / `b<blockId>_page`; the category page's native listing
lives on plain `?f_*`/`?page`.

### Ready platform block: "Product card" (`kind: "productCard"`)

A ready card: `props.card.productId` — and the storefront
draws a full card (the Simple/Classic/3D template lives IN THE PRODUCT,
`card_template`, and is not duplicated in the block). You can build your own card
from HTML and `{{ product.* }}`/vz keys by binding the template to the right resource (§16).

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

### Bundled blocks — created automatically

A `type='product'` category ALREADY gets a "Category products" block
+ the `__page:top` manifest on creation; a product gets a "Product card" block. After
`POST /categories` / `POST /products` do NOT create these blocks by hand — first
read `GET /categories/{id}` (`content_blocks` in the response) and work with the
existing ones.

A bundled section is marked with a ROLE: `payload.role: "bundled"` (a "technical"
block). Role rules: do NOT delete such a block (the editor does not allow it, and
the migration fix-up will restore it) — to hide it from the page, raise the flag in the
manifest: `__page:top` → `payload.page.hideSystemBlock: true` (the
"Hide technical blocks" checkbox in page settings; reversible). Your own blocks do not set the role —
it is reserved for the platform.

### Design bindings (Sh-1): a template instead of a copy

A resource can be RENDERED by a library block without its own copy — via a binding:

```bash
# assign your body group to a section's products (inherited down the tree)
PUT /design/bindings/category_children/{categoryId}
{ "slots": { "body": <groupId> } }

# what actually applies to the resource (with "inherited from…")
GET /design/bindings/product/{id}
# → { own: [...], resolved: { body: {block_id, source: "category:10"} } }

# a block's reach before editing/deleting ("will affect N pages")
GET /content-blocks/{id}/usage   # → { total, refs[] } (refs: bindings + manifests resource_type "layout"/"zone")
```

Resource types: `shop` (id `0`) · `category` (the page itself) · `category_children`
(the products inside) · `product` · `article` · `theme` (id = the LAYOUT group, §2b) ·
`system` (a storefront system page by its registry number, §2b).
Slots of the current model — `header` · `feed` · `body` · `footer` · `theme`; the
`theme` resource additionally has per-kind bodies `body_product` · `body_category` ·
`body_news` · `body_article` (these are LAYOUT slots, not page slots); `system` has only
`theme`/`header`/`footer`. A slot has THREE answers: absent from the map = inherit, `0` =
"nothing here" (the ladder stops — this is how a header is switched off), id = this group.
The legacy pair `layout`/`chrome` (manifest block `kind=chromeKit`) keeps
working in old shops but is not used for new code — the full model
is in the area `GET /docs/chrome` §3.1–3.6. The chain resolution arrives as the
`design` field in `GET /categories/{id}` and the public by-slug (for an article — in
`GET /articles/by-slug/{slug}`). A mutation sends the webhook
**`design.changed`** `{company_id, resource_type, resource_id, slots}` — only
from the prod contour (dev edits arrive by publication).

### Images in HTML: optimise nothing by hand

Put a plain `<img src="…">` linking to your file — **the server does the
image resizing** when serving the document: it adds `/w/{width}` + `srcset`
640/1024/1600 + `sizes="100vw"` + `loading="lazy"`. The original never reaches
the markup, even if you linked a multi-megabyte PNG.

What the server does NOT touch (and why):
* links to OTHER domains — our resizer does not serve them;
* `svg` and `data:` — we do not rasterise vectors or rewrite inline data;
* markup where you set `srcset` YOURSELF — we assume responsiveness is handled.

Set the slot size with styles and a matching `sizes`; for `vz-src` use the
ready `srcset` from the scope (`GET /docs/own-markup` §6). Styles alone are not enough.

## 6. Assemble the page and bind the blocks

```bash
# slug taken? check BEFOREHAND — otherwise 409 AlreadyExists SLUG_TAKEN
GET /categories/by-slug/promo-vesna?company_id={id}     # 404 = the slug is free

# page = a type='page' category
POST /categories
{ "item": { "name": "Промо весна", "type": "page", "is_published": true,
            "seo": { "slug": "promo-vesna" } } }
# → { "result": { "id": 42, … } }   ← category ids are NUMBERS (see §0)

# ZONE TABLE OF CONTENTS (mandatory!) — a service block named __page:top,
# it tells the editor in which order to show the page's blocks
POST /content-blocks
{ "item": { "name": "__page:top", "sections": [ { "type": "text", "payload": {
    "v": 2, "kind": "zone", "refs": [136, 137] } } ] } }
# → { "result": { "id": 140 } }   (there is also __page:bottom — the bottom zone)

# binding is a REPLACE-SET: list ALL page blocks at once,
# INCLUDING the table of contents (otherwise its references are "broken")
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}` is a partial update; a non-empty `item.sections`
replaces ALL sections, so edit them from a fresh GET. For builder groups
keep the references consistent and `page_design_version` (`GET /docs/chrome` §3.1).

Likewise: `PUT /products/{id}/content-blocks`, `PUT /articles/{id}/content-blocks`.
One block can be bound to several pages.

**Why the table of contents is mandatory.** The storefront renders the page without it, but in
the admin editor the owner sees the blocks only as "other" — they cannot be
moved between zones. With the table of contents everything you created is immediately editable
by a human as ordinary blocks.

Table of contents rules:
- at most ONE `__page:top` and one `__page:bottom` per page;
- `refs` holds block ids in render order (numbers);
- page flags live ONLY in `__page:top`, next to `refs`:
  `"page": { "hideSiteHeader": true, "hideSiteFooter": true, "hideSystemBlock": true }`.
  Each flag switches off a whole LAYER, not a single block: `hideSystemBlock`
  removes the entire technical layer — card, feed, breadcrumbs, page title.
  There is no separate `hideBreadcrumbs` any more (removed 2026-08-20): it hid only
  the legacy breadcrumbs variant and did not touch the widget, i.e. it was a second mechanism for
  what the layer already does. If you find it in your notes — the flag is dead;
  the server stores it and changes nothing;
- when editing an existing page: read it whole
  `GET /categories/by-slug/{slug}?company_id={id}` (one block alone:
  `GET /content-blocks/{id}`), find the block named
  `__page:top` in `content_blocks[]`, take its `refs`, add your ids, send the table of contents via
  `PUT /content-blocks/{id}` and repeat the replace-set binding;
- ⚠️ do NOT set `"origin": "builder"` on blocks — it marks blocks created by
  the editor; with it your blocks disappear from the page on the very first save.

## 7. Site layout and the four markup modes of a level-1 html block

> ⚠️ This section is about an html block in the platform box. For a transferred folder (level
> 3, no box) the main document is `GET /docs/transfer`: there is not a single rule
> for it here.

Your HTML is **part of the page, not an iframe**: it lives inside the site's shared
container, inherits its font and colour, and sits next to the header, menu and footer.
So lay it out FOR this container.

### Layout numbers — take them from the API, not from memory

`GET /v1/storefronts/resolve?slug={shop}` → the `layout` field (public):

```jsonc
"layout": {
  "content_max_px": 1600,              // max site content width
  "container_padding_mobile_px": 16,   // container side padding
  "container_padding_tablet_px": 24,   // (< 640 / 640-1023 / >= 1024)
  "container_padding_desktop_px": 32,
  "breakpoint_tablet_from_px": 640,    // responsive layer boundaries
  "breakpoint_desktop_from_px": 1024,
  "content_box_max_px": 1536,          // USABLE box = 1600 - 2*32
  "margin_x_presets_px": [0,16,32,64], // presets of the "Side margin" panel
  "radius_presets_px": [0,8,16,32],    // "Corner radius" presets
  "radius_default_px": 16,
  "font_family": "ui-sans-serif, system-ui, sans-serif, …",
  "base_font_size_px": 16
}
```

### The mode is set by TWO block settings

### Seven recommendations for a boxed level-1 html block

1. Set outer margins/width via props if the panel should control them.
   External CSS needs a layout or level 3; local shadow CSS does not reach there.
2. Check text readability near the edges: the platform grid or your own padding is a design choice.
3. We recommend no unintended horizontal scroll at
   360/768/1920 px. For a carousel, check that its container is what scrolls.
4. Check the block alone and with neighbours: overlays, margins and menus must
   keep the intended look when text changes and when a neighbouring block is hidden.
5. Split the page into sections if the owner wants to rearrange or reuse
   parts. A whole page as one document is also allowed.
6. Use the platform grid for a look consistent with the template; your own columns and
   breakpoints — for your own system. Do not mix them by accident.
7. Check 360/768/1920 px and the boundaries of the chosen breakpoints: text is readable,
   controls are reachable, the menu is not clipped. The fixed matrix — `/docs/own-markup` §7.2.

**The key tool is the `.vz-inner` class and the `--vz-content-box` variable.**
The platform provides two ready classes and two variables inside your document.
On the storefront and in the editor level 1 uses Shadow DOM with basic hooks.
However, the level-1 editor does not yet get the same layout CSS as the storefront;
check the final look on the site. Level 2 is a separate iframe without
automatic transfer of these classes: include your styles explicitly.

| Tool | What it does |
|---|---|
| `.vz-inner` | keeps the layer in the site column: `max-width: var(--vz-content-box); margin-inline: auto` |
| `.vz-pad` | full width + the page's side padding: `padding-inline: var(--vz-gutter)` |
| `--vz-content-box` | site column width. ≈1536 px only for a full-width block on a wide screen; in other contexts the value is RELATIVE (block box minus margins) — do not depend on the number, use the variable |
| `--vz-gutter` | page side padding: 16 / 24 / 32 px by step |

Your root element always spans the full block width — draw the background on it, and wrap text and
grid in `.vz-inner` (on the site grid) or `.vz-pad` (full width but
with padding from the edges). The panel's "Content inside" switch (available ONLY
on a "full width" block, `blockWidth:"full"`) changes the value of `--vz-content-box`:
`content` → site column, `full` → 100% (limit removed).

| # | Block settings | How it looks | How YOU lay it out |
|---|---|---|---|
| **A** | `blockWidth:"content"` (default) | the block is already in the site column | lay out at `width:100%`, no `max-width` needed. For long text set a readable line measure: `.text{max-width:66ch}` — a 1536 px column gives ~200 characters, which is unreadable |
| **B** | `blockWidth:"full"` + `contentWidth:"content"` | background to the screen edges, content on the site grid | background/gradient on the root element; text and cards inside `<div class="vz-inner">`. The most common mode for heroes |
| **C** | `blockWidth:"full"` + `contentWidth:"full"` | full width, no limits | limit it yourself: `.wrap{max-width:1536px;margin-inline:auto;padding:0 32px}` (take the number from `layout.content_box_max_px`) |
| **D** | `blockWidth:"full"`, `contentWidth:"full"`, `marginX:0`, `blockRadius:0` | edge to edge, no margins or rounding | `width:100%` with no inner limits — for maps, sliders, full-screen photos. ⚠️ Radius must be 0: otherwise the corners get cut right at the screen edge |

Hero skeleton (mode B) — remember this pattern:

```html
<section class="hero">                    <!-- full-width background -->
  <div class="vz-inner hero__body">       <!-- content in the site column -->
    <h1>Заголовок</h1><p>Текст</p>
  </div>
</section>
<style>
  .hero { background: linear-gradient(120deg,#101828,#1d2939); color:#fff; }
  .hero__body { padding: 64px 32px; }     /* your own inner padding */
</style>
```

**How to choose:** a text section, cards, a form → **A**. A hero with a full-width background
and text on the site grid → **B**. Your own non-standard wide grid →
**C**. A full-screen image/map/slider → **D**.

Section JSON for each mode:

```jsonc
// A — content width
"props": { "blockWidth": "content" }
// B — full-width background, content in the site column  ← recommended for heroes
"props": { "blockWidth": "full", "contentWidth": "content" }
// C — full width, you limit it yourself
"props": { "blockWidth": "full", "contentWidth": "full" }
// D — edge to edge, no margins
"props": { "blockWidth": "full", "contentWidth": "full", "marginX": 0, "blockRadius": 0 }
```

### What the wrapper does with level-1 block content (important)

- **It is not the strip that clips but the host of your markup.** The block strip itself (`div.vz-box`)
  clips nothing: shadows, glows and overlays extend beyond it fully. But
  the Shadow DOM host of an html block clips BY DEFAULT — to the rounding, with a
  2 px margin so glyph overhangs are not cut. So a dropdown or modal from
  your markup gets clipped at the block edge until
  `props.overflow: "visible"` is set (§17.0). Still, do not press text right against the border —
  give it its own inner padding (`.vz-pad`, `.vz-inner` or
  your own `padding`).
- **Vertical gaps between blocks do NOT add up.** There is one seam between neighbours:
  `max(marginBottom of the upper, marginTop of the lower)`; if EITHER of the two
  is set explicitly to `0`, the blocks touch (zero beats everything); both empty — the system
  24 px. The seam is drawn once, as the lower block's margin-top; `marginBottom`
  applies "as is" only on the last block of the feed.
- **For a straight edge you can choose `blockRadius: 0`.** Rounding at the edge
  is also fine if it is the intended design.

### Margins and radius — via the panel or your own CSS

Vertical gaps (`marginTop`/`marginBottom`), side air (`marginX`) and
rounding (`blockRadius`) are convenient to set as **block props**: then
the owner can adjust them in the panel, and the block stays consistent with its neighbours.
You can use `layout.margin_x_presets_px` / `radius_presets_px` or your own CSS.

### Typography and colour

Font and text colour are **inherited** from the page (`layout.font_family`,
`base_font_size_px`). Want a branded look for the block — set `font-family` explicitly;
want to blend with the page — **do not touch** `font-family` at all. Headings
can be scaled with `clamp()`, rem or vw — check readability at different widths.

### Positioning and block boundaries

Use the matrix `/docs/own-markup` §7.2: a query container is not a fixed
containing block in all browsers. Check scrolling and geometry on the target
surface; `props.overflow:"visible"` removes host clipping but does not change
Shadow DOM isolation.

## 8a. Level-1 html block markup rules (Shadow DOM, sanitising)

> Does not apply to a level-3 folder: there it is the real DOM, scripts execute, anchors
> work, `html/body/:root` are rewritten to the project wrapper (§2a).

1. **What the server strips (level 1):** `<script>`, `on*` attributes
   (`onclick`, `onerror`…), `javascript:` links, `<iframe>`, `<form>`/`<input>`.
   **What survives:** `<style>`, inline styles, classes, `id`, semantics
   (`section/article/header/footer/figure/picture/source`), `srcset`,
   data-URI images, `<link rel="stylesheet" href="https://…">` (https only).
2. **Isolation (level 1):** local CSS selectors do not leave the Shadow DOM.
   CSS for the outer wrapper must be outside. The layout/shadow/editor
   and cascade matrix — `/docs/own-markup` §7.2; author classes — §5.5.

3. **Inheritance:** font, text colour and theme CSS variables get inside —
   do not reset them without need (`all: initial` only if you need an independent look).
4. **Width** — see §5, the markup rule.
5. **Responsiveness** — media queries and `max-width: 100%` for images; the page must
   work at 360 px.
6. **Anchors (level 1):** `#anchor` inside a block does not scroll the page
   (Shadow DOM boundary) — do not build navigation on anchors inside one document.
   On level 3 anchors work.
7. **Size:** keep the document under ~256 KB; the hard limit is 2 MB (above it the server
   will not serve it, and the block degrades to an iframe without SEO).
8. **Keep others' keys:** when editing an existing block, do not drop
   unknown `payload` fields — they may belong to other features.

## 8. Quizzes and interactivity (level 2)

Need JS — create a document with `"level": 2`. It renders in an iframe sandbox
(`sandbox="allow-scripts"`, no access to the site or cookies), scripts work,
**but the content does not reach search results**. Recipe: interactivity at level 2,
and SEO text as a separate level-1 block on the same page.

## 9. Check the result

```bash
# 1) the page serves your text in RAW html (no JS) — that is SEO
curl https://{shop}/promo-vesna | grep "Заголовок из моего HTML"

# 2) the document's sanitised content (public)
curl "https://api.vizen.shop/html-documents/10/content?company_id={id}"
# → { "html": "…", "level": 1, "updated_at": "…" }

# 3) where the document is used
curl "https://api.vizen.shop/html-documents/10/usage" -H "Authorization: Bearer …"
```

If `…/content` returns **404**, the document is not yet bound to any block
(by design: unattached drafts are not publicly readable), or it has
`level: 2`, or no file was uploaded.

## 10. Errors and limits

The error shape depends on the transport. RPC endpoints usually answer
`{ "error": "rpc error: code = <CODE> desc = <MARKER>" }`; separate HTTP endpoints
return a short `error` and extra fields. For example, release
publication: `422 {"error":"RELEASE_INVALID","report":{"errors":[…]}}`, where
each finding has `level`, `code`, `message`, plus `file` and `ref` when present. The public
`POST /docs/validate` with an invalid body gives `400 {"error":"invalid_request","hint":"…"}`,
and with a valid body — `200` with `ok` and `findings[{path,status,why}]`.
Keep the HTTP status and the whole report; do not look for `ref` only at the top level.
Diagnosis order and response shapes — `GET /docs/troubleshooting` §2a.

| Situation | HTTP | Marker in `desc` |
|---|---|---|
| Missing scope on a PRIVATE endpoint | 403 | `PAT_SCOPE_MISSING (required_scope: …)` |
| The endpoint is not open to tokens at all | 403 | `PAT_METHOD_NOT_ALLOWED` |
| No `catalog:read`, the endpoint is PUBLIC | 200 | there is NO error response: the anonymous projection arrives (no drafts or hidden items). On your own unpublished page — `404 …_NOT_FOUND`; without `company_id` — `403 AUTH_COMPANY_PROBLEM` (`/products`) or `403 COMPANY_ID_PROBLEM` (`/categories`, `/articles`). So **always pass `company_id`** |
| Someone else's/non-existent/hidden object | 404 | `…_NOT_FOUND` (a shared mask — existence is not revealed) |
| Deleting a document used by a block | 400 | `DOCUMENT_IN_USE: used by N content blocks` |
| Deleting a file used by a document | 400 | `FILE_IN_USE` |
| Page/category slug taken | 409 | `AlreadyExists … SLUG_TAKEN` (check `by-slug` beforehand) |
| `page.number` not passed to `/articles` | `400` (numbering from 1; optional for `/products`) |
| Image over 10 MB | 400 | `FILE_TOO_LARGE` on `/done` (compress before uploading) |
| Too many requests | 429 | `RATE_LIMITED` + `Retry-After` header (seconds). Per-company limits per minute: reads 600, writes 240, storage mutations 180 — wait for `Retry-After` and retry |
| Block data (`sections`) | ≤ 64 KB — do NOT embed HTML, only `html_document_id` |
| html document size | recommended ≤ 256 KB, maximum 2 MB |

## 11. "Page ready" checklist

### 11.1. Ready site / folder (level 3)

- [ ] A dev-contour key during iterations; prod — only the final publication.
- [ ] All paths are relative, inside the folder; external resources `https://` only.
- [ ] Own base.css under the root class: headings, lists, links, buttons do not
      depend on browser defaults (the storefront preflight resets them).
- [ ] The script starts immediately and re-initialises on `vz:navigate`.
- [ ] Shop data is rebound (`vz-for`, `{{ product.* }}`, `vz-add-to-cart`,
      `href="form:<id>"`); links lead to real slugs.
- [ ] Opened by direct address AND by following a link within the site; screenshots at
      1440 and 390; console without errors and without 404s on `/_html/`; `/cart` shows
      the header.
- [ ] `--doc <id> --list` shows the active release; I know the rollback command.
- [ ] `curl` of the page shows my text in raw HTML.

### 11.2. Level-1 html block inside a page built from widgets

- [ ] Read the shop and catalogue; links lead to real slugs.
- [ ] Images uploaded once, inserted via `/w/{width}/webp`, assets
      registered in the document.
- [ ] My CSS and props give the expected width; no unintended
      horizontal scroll or content overlap.
- [ ] The page reads well at 360 px.
- [ ] `curl` of the page shows my text in raw HTML.
- [ ] The block is bound, the page is `is_published: true`.

## 12. How to report to the owner (MANDATORY)

You do not work in a vacuum: a human accepts the result. Half of the "bugs"
they see are really an unclear handover. Rules:

### 12.1. A dev key does NOT publish — and that is normal

> Exception: the owner may grant the key the **`publish:write`** right (the
> "Allow publishing the draft" checkbox when issuing it). Then
> `POST /v1/dev/publish` is available to you — but press it ONLY when the owner explicitly asked
> "publish", never on your own initiative. The WHOLE shop draft is published,
> so before publishing look at what will go out: `POST /v1/dev/publish/preview`
> (a dry run, changes nothing) and `GET /v1/dev/status` — and show the summary
> to the owner. A `400` refusal with
> `FailedPrecondition ORG_NOT_PUBLISHABLE` — the shop is blocked, under
> review or scheduled for deletion: publication is closed until the platform
> decides; retrying will not help — tell the owner.

A key in the "draft" contour creates objects in the draft version of the site. They are NOT on the live
site until the owner presses "Publish". Therefore:

- **A 404 on the live site right after your work is the expected result**, not
  your error. Do not try to "fix" it with repeated writes.
- Self-check: `GET /categories/by-slug/{slug}?company_id={id}` **with the token**
  → `200` (the draft is there), **without the token** → `404` (not published yet).
  Exactly this pair of responses = the work is done right.
- The draft storefront does NOT open by direct link, even for the owner: it is
  available only from the admin (owner session), so the draft does not reach
  visitors and search engines. Do not give links like `slug--dev.vizen.shop` —
  they will not work.

### 12.2. Links in the report — full and clickable only

Bad (the owner cannot open it, the link is dead):

```
Done, page /dvukhyarusnye-pod-zakaz — see the admin
```

Good:

```
Done: page "Двухъярусные кровати под заказ"

Public address (will work after publication):
https://{slug}.vizen.shop/dvukhyarusnye-pod-zakaz

Currently in the draft. To view and publish:
1. Open the shop admin
2. Switch to DEV mode (the orange switch)
3. Press "Publish"
```

Link rules:
- **always an absolute URL with the scheme** `https://…` — a relative path
  `/promo` is not clickable in chat;
- take the storefront domain from `GET /v1/orgs/current` (`slug` → `https://{slug}.vizen.shop`)
  or from the shop's custom domain; do NOT make it up;
- link to a product/category by its real slug from the API response, not by id,
  if it has a slug;
- if the object is in the draft, write "will work after publication" next to the link.

### 12.3. What the final report must contain

1. **What was created** — as a list, with human names (not ids).
2. **Where to look** — full URLs + a draft note.
3. **What the owner should press** — concrete steps to publication.
4. **What did NOT work** — honestly, with the reason. If an endpoint answered
   `PAT_METHOD_NOT_ALLOWED` (for example, storefront settings, logo, currency —
   they are closed to tokens), say so: "not available via the API, do it
   in the admin here". Do not stay silent and do not pass a partial result off as complete.
5. **What is left for later** — if the work is split into stages.

### 12.5. Prod or draft — determine it BEFORE starting work

First of all, ask the API where your key writes:

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

Then behave differently — and **be sure to tell the owner in your very first
message** which mode you work in:

| Key | What you tell the owner | What you may do |
|---|---|---|
| Draft | "I am working in the draft — the live site will not change until you publish" | create/edit anything; publishing is not allowed (or only on a direct request, if `publish:write` was granted) |
| Full access | ⚠️ "Attention: edits go STRAIGHT to the live site, customers see it" | everything; ask for confirmation before bulk edits |

Safety rule: **with full access, do not run bulk operations silently.**
Deleting 50 products or re-uploading the catalogue — ask first, then do it.

### 12.6. How to build links (the main source of confusion)

The shop address is NOT made up — it comes from the API:

```bash
curl https://api.vizen.shop/v1/orgs/current -H "Authorization: Bearer $TOKEN"
# → { "slug": "myshop", ... }  → storefront: https://myshop.vizen.shop
```

If the shop has its own domain connected, the storefront lives there — then give links
to it, not to `*.vizen.shop`.

| What | How to build | Example |
|---|---|---|
| Home | `https://{slug}.vizen.shop/` | `https://myshop.vizen.shop/` |
| Page/section | `https://{slug}.vizen.shop/{seo.slug}` | `https://myshop.vizen.shop/promo-vesna` |
| Product | `https://{slug}.vizen.shop/{section}/{seo.slug}` | `https://myshop.vizen.shop/krovati/avrora-120` |
| Draft | **do NOT give a link** | opens only from the admin |

Check yourself before sending the answer:
- the link starts with `https://` (a relative path `/promo` is not clickable in chat);
- it has the real slug from the API response, not an id or a made-up name;
- if the object is not published yet, "will work after publication" stands next to it.

### 12.7. Final message template

```
Done. I worked in the DRAFT — nothing has changed on the live site yet.

What I did:
• Page "Промо весна" — 4 sections, 12 products from your catalogue
• Uploaded 9 photos (taken from the product cards, no new duplicates)

Where it will be available after publication:
https://myshop.vizen.shop/promo-vesna

To publish:
1. Open the shop admin
2. Switch to DEV mode (the orange switch)
3. Press "Publish"

Did not work: could not set the logo — storefront settings are closed to
API keys (error PAT_METHOD_NOT_ALLOWED). Set it in the admin:
Company → Storefront → Logo. The file is already in the media library.
```

A bad answer looks like this — **do not do this**:

```
Done, created everything. See /promo-vesna in the admin.
```

(it does not show what was created; the link is not clickable; nothing about the draft and
publication; silent about what did not work)

### 12.4. While working

- Say what you are doing in short steps: "reading the catalogue → 32 products",
  "uploading 9 photos", "assembling the page". Ten minutes of silence in chat = the owner
  thinks everything has hung.
- Hit an API error — **show its text**, not "something does not work".
  The owner can pass the error code (`SLUG_TAKEN`, `FILE_TOO_LARGE`) to the
  platform developer.
- Do not invent data: take prices, names and photos from the shop catalogue.
  A made-up product on the storefront is worse than an empty section.

## 13. FORMS AND LEADS

The full contract of forms, leads, CRM sandboxes and the two testing modes —
[`GET /docs/forms`](/docs/forms). Also there: the block `props.form.formId`, the button
`form:<id>` and the level-2 HTML bridge (`vizen-form-open` / `vizen-form-submit`).

## 14. MODAL CONTENT FROM HTML

Forms and their buttons moved to `/docs/forms`; the product and design modals below
remain part of this guide. The number §14.3 is kept for existing links.

### 14.3. Modal window from HTML: a product or a ready design group

#### Product: three opener forms

The full window command is carried by ONE `modal:` string — what is written in it is
what gets drawn:

```
modal:product:<id>[?size=small|medium|full&head=0|1&foot=0|1&pad=<px>
                   &cls=<name>&tpl=<groupId>&nav=ids:42,43&v=<sku>]
```

```html
<!-- 1. A command link: it has no real product address -->
<a href="modal:product:42?size=full">Кресло</a>

<!-- 2. A real href + the command in the key: the address stays for SEO,
        Cmd/Ctrl+click, middle click and working without JavaScript -->
<a href="/catalog/chair" vz-modal="modal:product:42?size=full">Кресло</a>

<!-- 3. Any element with the key — a button, tile, image -->
<button type="button" vz-modal="modal:product:42?foot=1&nav=ids:42,43">
  Быстрый просмотр
</button>

<!-- Short form: id only, default window -->
<a href="/catalog/chair" vz-quickview="42">Кресло</a>
```

| Parameter | Values | Default | What it does |
|---|---|---|---|
| `size` | `small` · `medium` · `full` | `medium` | `small` = 480px, `medium` = site content width + margins (up to 1648px on a wide screen), `full` = the whole screen. `std` is the legacy name of `medium`, still accepted |
| `head` | `0` · `1` | `1` | the window header (product name + close cross) — on all sizes, including `full`. `head=0` removes it, the cross becomes floating |
| `foot` | `0` · `1` | `0` | the window footer. Only it holds the ←/→ arrows, the "3 / 12" counter and the ↗ link to the product page |
| `pad` | integer `0`…`96` | `16` under the header, `0` without it | the WINDOW's top inner padding above the card, px. `pad=0` — the author's markup starts right at the window edge |
| `close` | `dark` · `light` | `dark` | tone of the floating cross: `light` — a white circle for dark markup. The frame has no other colours |
| `cls` | class name `^[a-z][a-z0-9_-]{0,63}$` | none | CSS hook of THIS window: the frame gets the class `vz-qv-<name>`, and global CSS targets exactly it |
| `tpl` | id of a product group of the `body` slot | none | draw the window body with this group; without it the usual product page design ladder applies |
| `nav` | `ids:42,43` · `ns:<ns>` · `cat:<id>` | none | where the neighbouring products for paging come from |
| `v` | variant sku | none | the product's starting variant |

The platform writes into the string only what DIFFERS from the default, so an address it builds
is always short: `modal:product:42` and `modal:product:42?size=medium` are the same
window.

#### Window CSS hook: your CSS on the frame

The window FRAME lives in the same page's document, so the layout's global CSS (and
level 3) reaches it. ⚠️ It reaches the CONTENT only for `modal:group:`. With
`modal:product:` the frame contains a separate document (iframe): a rule like
`.vz-qv--small .my-card {…}` never gets there — restyle the card with its
own `@media` (the frame viewport = the window width). Frame classes:

```
vz-qv                 — any window
vz-qv--small|medium|full   — window size
vz-qv--product|group       — what is inside
vz-qv--head, vz-qv--foot   — has a header / has a footer
vz-qv-<name>               — your own name from ?cls=<name>
```

```html
<a href="/catalog/chair" vz-modal="modal:product:42?cls=promo&pad=0">Кресло</a>
<style>.vz-qv-promo { --frame: #111; }</style>
```

The same values are duplicated as the attributes `data-vz-qv-size` and `data-vz-qv-kind`.

**What the product window lacks — provide it yourself.** On a page a block gets side
margins from the wrapper (`.vz-inner`); the window has no wrapper — a body template transferred as
is will sit against the edge. With `head=0` the cross floats ON TOP of the content; there is no safe
zone for it — leave ~48 px free at the top right. A sticky purchase bar in the frame
stays sticky. The content does not get an "I am in a window" signal today (a known
gap, tracked as a separate task).

⚠️ **The two window kinds work differently, and your `@media` depend on it.**

* `modal:product:<id>` — the content goes into an **iframe** with ITS OWN viewport
  as wide as the frame. The card's media queries inside such a window work as on a
  narrow screen: `size=small` (480 px) really switches on the single-column
  layout written for `@media (max-width: 1000px)`.
* `modal:group:<id>` — the content is drawn **inline, in the same document**, and
  the viewport stays the page viewport. The frame changes the width, not the screen, so
  `@media` inside stay silent: on a wide monitor a card in a 480 px window stays
  two-column.

For the second case rebuild the layout by the frame classes
(`.vz-qv--small .my-card { grid-template-columns: 1fr }`) — that is exactly what
they are for — or with container queries on your own container: the window frame does not create
the platform's own query containers (`/docs/own-markup` §7.2).

⚠️ **There are no companion keys `vz-modal-size`, `vz-modal-tpl`, `vz-modal-nav` in your own
markup.** The key whitelist (`GET /docs/vz-keys.json`) has only
`vz-quickview` and `vz-modal` for the window; the level-1 sanitiser strips companions silently.
So the only full path for author markup is the `modal:` string.

#### What happens in the window

- the window body is the product page's `body` slot, assembled by THE SAME design
  ladder as the page itself (`tpl` substitutes the group). The SITE's header, menu and
  footer are not in the window; the page background is only dimmed;
- the `breadcrumbs` widget is hidden in the window by default — breadcrumbs are page
  navigation, not card navigation. To bring them back on a specific section: `props.showInModal:
  true`;
- a click on an opener INSIDE the window changes the product in the same window — no second window
  opens, history does not grow. Parameters the new target is silent about
  (`size`, `head`, `foot`, `pad`, `close`, `nav`) are inherited from the open
  window, while everything set in it explicitly — including a value equal to the default
  (`?size=medium&head=1&foot=0` written inside a full window) — beats
  inheritance; `tpl`, `v` and the real product address are not inherited;
- desktop keyboard: ←/→ — neighbouring product (by `nav`), ↑/↓ — gallery
  image, Esc — close. In input fields and under an open lightbox or form
  keys go to them. On a phone, swiping sideways pages through products, swiping down
  closes the window;
- the window frame is preloaded on hover over the opener — the window opens without
  a white frame.

#### Ready inner content: a design group

Modal content is not passed as an HTML string in the opener and not loaded with an
arbitrary public `GET group by id`. It is assembled from standard blocks,
combined into a group, and the button refers to the group:

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

<!-- For a link the same command is allowed right in href: -->
<a href="modal:group:1727?size=full">Открыть презентацию</a>
```

How to create the content with API requests:

```http
# 1. Create one or more ordinary widget blocks (§5).
POST /content-blocks
{ "item": { "name": "Тело акции", "sections": [ ... ] } }
# → result.id = 1726

# 2. Put them into a design group. The order of widgets = the order inside the window.
POST /content-blocks
{
  "item": {
    "name": "Модалка акции",
    "group_type": "design",
    "sections": [{
      "type": "text",
      "payload": { "v": 2, "kind": "group", "props": { "widgets": [1726] } }
    }]
  }
}
# → result.id = "1727" (int64 in JSON may be a string)
```

If the inside must be entirely your own, make block `1726`
`kind:"html"` per §2–§5 and style the HTML/CSS inside it. The window title,
overlay, closing, focus and the responsive sheet belong to the platform; the group's
content is yours. Margins/radius of individual blocks inside are set with the standard
wrapper props (§17), not with CSS selectors of the parent window.

#### Declare the dependency in the HTML block payload

The backend serves only groups referenced by an already public page. For an
HTML document the target must be declared in a structured way next to the document — this is
not a second opener but a list of server-side dependencies:

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

This is mandatory for `modal:group` from HTML, especially level=2: its source
lives as a file and may compute the id in a script, so the server does not guess the target from
the text. A product quick view does not need `modalTargets` — a product has its own
public route. At most 20 groups and 100 of their widgets are materialised per page;
a foreign, broken or feed group is skipped.

In the full page response (`GET /products/{id}`, `/categories/by-slug/...`,
`/articles/by-slug/...`) the ready content arrives as a sibling:

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

Requesting the group separately from the browser is neither needed nor allowed: the storefront already
registers `modal_groups` in the page's single modal host.

#### Level 2: the same markup or a programmatic call

In a level=2 sandbox an ordinary DOM click does not bubble into the page. The platform itself
injects a safe bridge for `[vz-quickview]`, `[vz-modal]` and
`a[href^="modal:"]`, so the buttons shown above work without your JS.
If the target is computed after a request to the public backend, send the same
canonical string by hand:

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

// Product + a real fallback URL:
parent.postMessage({
  type: 'vizen-content-modal-open',
  href: 'modal:product:42?size=full',
  resourceHref: '/catalog/chair'
}, '*');
```

The message goes through the same strict parser as `href`: an unknown kind, a zero
id, a size outside `small|medium|full`, control characters and overly long strings
are dropped. Do not put a PAT/admin token into browser HTML: the agent or your server
creates blocks and groups with it; the visitor reads only the public page
response. An unknown safe query parameter is ignored for compatibility.

The platform lightbox is opened from a sandbox the same way, with its own message
`vizen-lightbox-open` — fields and examples: `/docs/modules` §2.

## 15. PAGE DESIGN: saw a block → found its source → changed it

This section answers a question that used to be solved by guesswork: "I see a
header with a menu on the site — what is it and where do I edit it". The order is as follows.

### 15.1. Read the labels from the page

Every storefront block is labelled in the markup:

| Attribute | What it means |
|---|---|
| `data-vz-kind` | widget kind (`siteHeader`, `siteMenu`, `productListing`, …) |
| `data-vz-block` | id of the design block the block came from |
| `data-vz-kit` | id of the chrome KIT — instead of `data-vz-block` on header/menu/footer |
| `data-vz-idx` | the section's position inside the block (or kit) |

⚠️ **Chrome has no source block number.** The storefront gets the kit already
assembled, and the header, menu and footer blocks themselves are not named in the response —
that is why `data-vz-kit` is there. To find the editable block of a part, read the
kit block: its `kind: "chromeKit"` section holds `props.kit` like
`{"top": [2084, 2085], "bottom": [2086]}` — those are the header, menu and
footer blocks by zone, in output order.

```bash
curl -s https://<shop>.vizen.shop/<page> | grep -o 'data-vz-[a-z]*="[^"]*"'
```

Elements INSIDE a widget are labelled separately — `data-el` (logo, navigation,
icons) and `data-edit` (text editable in place). With them you address an edit
more precisely than "somewhere in the header".

### 15.2. Ask for the widget's properties

```bash
curl -s https://<shop>.vizen.shop/api/widgets
```

The PLATFORM reference (no token needed): for each kind — an RU/EN label, whether the strip
is chrome or a page block, and the full list of properties with defaults. The properties there
are not rewritten by hand: they come from the widget's own parser, so the list cannot
diverge from what the storefront understands.

### 15.3. Take the block and edit the needed section

```bash
# 1) the whole block (catalog:read right)
curl -s -H "Authorization: Bearer $VZ" $API/content-blocks

# 2) the edit: sections are a replace-set, so send the WHOLE list,
#    changing section No. data-vz-idx in it.
#    The method is PUT (not PATCH): partial update here is expressed by the item field.
curl -s -X PUT -H "Authorization: Bearer $VZ" -H 'Content-Type: application/json' \
  $API/content-blocks/<data-vz-block> -d '{ "item": { "sections": [ … ] } }'
```

⚠️ `sections` is a **replace-set**, not a partial edit: send one section and
the others disappear. Always read the block, change the needed list element, send
the whole list.

⚠️ The `catalog:write` right is required. The live site is edited with a `prod` contour key;
a dev key writes into the draft, and a human publishes (the `publish:write` right is granted
separately, with a checkbox when the token is issued).

### 15.4. Check with your own eyes

Open the same page and make sure the changed block's `data-vz-block` is the same
and the content is new. If the block vanished, it is almost always the replace-set from 15.3.

## 16. PAGE LAYERS: your markup instead of someone else's, not on top of it

A page is assembled from three layers, and each setting switches off a WHOLE LAYER.
There are no separate switches per widget (for example, "hide breadcrumbs") and there will be
none: breadcrumbs are an ordinary widget; they can sit in any layer and disappear together
with their layer.

| Layer | What is inside | Flag |
|---|---|---|
| Layout, top | header, menu and everything the owner added to the top zone | `hideSiteHeader` |
| Layout, bottom | footer and everything added to the bottom zone | `hideSiteFooter` |
| Technical | everything that came with the product or section: card, product list, breadcrumbs, page title | `hideSystemBlock` |
| Your blocks | what you placed yourself | no flag — just delete what you do not need |

The flags live in the `kind:"zone"` section of the block named `__page:top`.

### 16.1. Scenario A — a unique landing (ONE page)

Create an empty page and switch off all three layers on it. Then your markup occupies
the whole page and nothing from the platform shows through it.

```bash
# 1) read the page's zone block (its id is in data-vz-block, or look for the name __page:top)
curl -s -H "Authorization: Bearer $VZ" $API/content-blocks

# 2) switch the layers off — sections stays a replace-set, send the WHOLE list
curl -s -X PUT -H "Authorization: Bearer $VZ" -H 'Content-Type: application/json' \
  $API/content-blocks/<id> -d '{"page_design_version":1,"item":{"sections":[
    {"type":"text","payload":{"v":2,"kind":"zone","refs":[<ids of your blocks>],
     "page":{"hideSiteHeader":true,"hideSiteFooter":true,"hideSystemBlock":true}}}
  ]}}'
```

Nothing is deleted by this: remove the flag and the layer comes back as it was.

### 16.2. Scenario B — a unique HEADER (the whole site)

⚠️ A common and costly mistake: laying out your own header inside a landing. Then it
appears on ONE page, while all the others keep the old one — the site
ends up with two different headers.

The header lives in the **layout**, in the `top` zone (the footer in `bottom`). Layout parts are shared
between pages, so an edit spreads across the whole shop at once. The chrome
kit is a `kind:"chromeKit"` section, `props.kit = {top:[id…], bottom:[id…]}`;
on the page its parts are labelled `data-vz-kit`.

Selection rule: **one page → switch off layers on the page; the whole site → edit the
layout.**

⚠️ The word "layout" in this section refers to chrome ZONES (the `chromeKit` kit,
old-model shops). Since 2026-09-12 the platform has a SITE LAYOUT —
a `group_type:"theme"` group with its own folder of files and code (§2b). Header and
footer are chosen in it with the `header`/`footer` slots, and the site's shared CSS/JS/fonts
go into its folder, not inside the header's html widget: on the live site the CSS
of a level-3 header applies to the whole page, but the editor frame loads only the
layout's `<head>`, the layout is connected before the first frame, and a header removed from
a section takes its styles with it.

## 17. BLOCK WRAPPER: width, margins, radius — also via the token

The platform draws every page block inside a standard wrapper, and it
is configured by THE SAME section as the widget itself. The keys sit next to `kind`, not
inside the widget's `writePath`:

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

⚠️ The axes live at the ROOT of `props` — next to the widget's nest (`html`, `listing`, …),
not next to `kind`. An axis placed on the section is stored and draws nothing:
`POST /docs/validate` answers it with `ignored` and the hint "its place is
props.<axis>". Check with this, not by eye.

The full list of axes with types and defaults — `GET /docs/widgets`, section `wrapper`.
Numbers are TAKEN from there, not remembered: every axis and every default of the wrapper
divs themselves carries `anchor.at` in the response — the storefront `file:line` that sets
the value. The reference is rebuilt from code on every build.

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

Also there is `wrapper.ownDefaults`: kinds that have their OWN defaults.

**The HTML block is exactly such a kind.** Its platform padding and radius are
zero: the markup author controls the look. Previously a 24/12 band appeared around a landing
and the corners of a full-width strip were rounded — even though the block data contained
nothing but the width. Need air — set `paddingSides` explicitly; it wins.

Responsiveness: `props.tablet` and `props.mobile` with the same keys
(`props.mobile.paddingSides`); an unset layer takes the value of the wider one.

⚠️ `sections` is a replace-set: read the block, change the needed element, send
the whole list.

### 17.0. Your own class on the wrapper: how to make a block unlike the platform

The wrapper is TWO divs, each with its own defaults:
`div.vz-box` (the strip: background, borders, padding, radius, stickiness — and it clips
NOTHING by default) → `div.vz-inner` (the container: width cap to the site column and
centring). A **level-1** html block has a third one inside — the Shadow DOM host
`div.vz-radius`, and IT clips by default: a dropdown or modal from author
markup is clipped at the block edge until `props.overflow: "visible"` is set.
A transferred level-3 folder without `props.html.boxed` has none of the three
nodes: your root element is the block; the wrapper has nothing to sit on.

Two ways to make it unique; choose BEFORE laying out:

| What you need | Path |
|---|---|
| re-lay out the whole block — your own grid, your own breakpoints | **`props.wrapperClass`** — your own class on the strip, then your own CSS |
| switch off ONE behaviour (clipping, width cap, air, layer) | **a targeted axis** — `props.overflow`, `props.blockWidth`, `props.paddingSides`, `props.position` … |

```jsonc
{ "kind": "html",
  "props": {
    "wrapperClass": "promo-hero grid-2",   // up to 3 names separated by spaces
    "wrapperId": "prices",                 // one name: #anchor and your own scripts
    "html": { "html_document_id": 13 } } }
```

In the markup your own names come LAST; the platform's stay in place:

```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 }
```

Names are validated WHOLE and dropped whole: up to 3 classes, one id,
`^[a-zA-Z_][a-zA-Z0-9_-]*$`, up to 48 characters, and none may start with
`vz-` — that is the platform's namespace (`vz-edge` would widen the block past
`blockWidth`, `vz-vis-none` would hide it entirely). An error in a name costs you
the class but does not break the page.

**Works for chrome too.** `siteHeader`, `siteMenu`, `siteFooter` draw the strip
themselves but read the same `wrapperClass`/`wrapperId`: you can recolour and re-lay out the platform header
without replacing it with your own markup and without losing live search
and the cart counter.

CSS for the outer wrapper must live outside the local Shadow DOM. Author names
are free; the wrapperClass limits above apply only to the API field.
Stable names do not guarantee that a global override is safe.
Recipe and the single CSS matrix — `/docs/own-markup` §§5.5,7.2.

### 17.0.1. Stickiness

`props.position: "sticky"` (+ `props.stickyTop` in px if something sticky is already
on top) makes THE STRIP ITSELF sticky — the only node that has room to move.
Your own markup asks for the same with the `vz-sticky` key on the document's root node.

Where it works: any block of a page zone, any block of a layout zone (a chrome kit
is exactly the "own header" case), nested column blocks. Where it does NOT work:
the platform's `siteHeader`/`siteMenu`/`siteFooter` — the wrapper does not draw their strip
at all; the header has its own setting `props.sticky: true` for this; `vz-sticky` inside
columns (there only `props.position` is consulted); the editor canvas (the preview does not
consult it, the live site does).

For stickiness, check clipping/overflow of the ancestors. `fixed` reserves no space
in the flow, and its anchoring must be checked against the matrix `/docs/own-markup` §7.2.
Do not substitute it for sticky without checking the scrolling behaviour.

### 17.1. Images in author markup

Write a plain `<img src>` pointing to a shop file — the server itself swaps it for
the resizer and adds `srcset` (640/1024/1600, webp). The original never reaches
the markup.

**But set `sizes` yourself.** Only the author knows the slot width; without a hint
the server puts `100vw`, and the browser takes the LARGEST step. On a grid of cards
268px wide this meant 1600w instead of 640w — six extra megabytes:

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

The server does not touch your own `sizes`. Nor your own `srcset`: then no substitution
happens at all, responsiveness is entirely yours.
