# Own markup: your HTML, CSS and JS in a Vizen shop

> **Резюме по-русски.** Область своей вёрстки: ключи и подстановки, что делает
> обёртка блока с вашей разметкой, картинки, скрипты. Развилка с платформенным
> листингом описана первой, до рецептов, потому что именно она дороже всего
> стоит, когда не названа. С 2026-08-20 развилка ДРУГАЯ: в scope есть живая лента
> `products[]`, и своя вёрстка больше не обязана быть снимком каталога. Дорого
> теперь стоят её ГРАНИЦЫ — только страница раздела, до 48 позиций,
> фиксированный набор полей, — поэтому они названы там же, в §2. §5 переписан
> в тот же день: обёртка блока перестала быть тем, с чем борются, — у неё есть
> развилка «свой класс (`props.wrapperClass`) ↔ точечная ось», анатомия трёх
> div-ов и честная карта липкости по поверхностям. С 2026-09-12 добавлен §7.2:
> общий CSS/JS сайта живёт в ПАПКЕ ЛЕЙАУТА, а не внутри html-виджета шапки:
> кадр редактора грузит только `<head>` лейаута, а шапка, снятая с раздела,
> уносит стили с собой. С 2026-09-13 стили лейаута доезжают и ВНУТРЬ тени
> уровня 1 (§7.2, врезка в конце). Одна и та же вёрстка на многих страницах с
> разными данными — это уже компонент, `GET /docs/components`. Перенос готового
> сайта целиком — `GET /docs/transfer`.

**Status:** current · **Verified:** 2026-08-20, live calls + source measurements;
§7.2 — 2026-09-12, по коду (хендлеры, валидатор, витрина); стили лейаута в тени
уровня 1 — 2026-09-13, по коду витрины + прогон в headless Chrome
**Owner:** agent-api · **Serves:** `GET /docs/own-markup`
**Machine reference:** `/docs/vz-keys.json` (keys, scope, levels) · same table as prose: `/docs/vz-keys`
**Skill:** `vizen-own-markup`

## 1. What you can do here

Write ordinary HTML, CSS and JavaScript, put it in a page block, and have the
platform substitute shop data **on the server**. The response is plain HTML: the
text is in the page source, search engines see it, and no foreign JavaScript is
needed to display data.

## 2. Decide first

| If you want… | Take this path | Cost |
|---|---|---|
| a product row on a **section page**, any design | own markup over the `products[]` scope | live prices — but only the section this page is bound to, ≤ 48 items, a fixed field set |
| a product row **anywhere else** — a landing, a hand-picked selection, another section | own markup with the values written in | **prices and stock freeze** — see below |
| a product row with the platform look | platform `productListing` (`widgets.md`) | the look is bounded by props; the strip cannot be recoloured today |
| a header that is unmistakably yours | own markup **in a chrome kit** (`chrome.md`) | you rebuild the search box and the cart counter from `vz-` keys; sticky is no longer a cost — it works there (§5.7) |
| a **platform** block that must not look like the platform | keep the widget, put your own class on its band — `props.wrapperClass` (§5) | a level 3 document to hold the CSS (§5.4) |
| **the same markup on many pages, with different content each time** | a **component** — the same folder plus `component.json` and `{{key}}` (`components.md`, `GET /docs/components`) | you write a schema of parameters; in exchange one new release updates every insertion |
| a one-off landing page | own markup on an empty page with all three layers hidden | nothing of the platform shows through, which is the point |

**Where the data comes from is the fork that matters.** The page scope carries
`shop`, `menu`, `crumbs`, `category`, `products` and — on a product page —
`product`.

`products[]` is **live**. It is built on the server from the same catalogue call
the platform listing uses, so `price` and `old_price` have already been through
the promotion engine and `preview` is already a resizer path:

```html
<article vz-for="p in products">
  <img vz-src="p.preview" alt=""> <h3>{{ p.name }}</h3>
  <span>{{ p.price_text }}</span>
  <button vz-add-to-cart="{{ p.id }}">Buy</button>
</article>
```

Four boundaries. Inside them the row is honest; outside them it is a snapshot:

- **only on a catalogue section page.** The feed is the section the page is bound
  to, and the id comes from the page, never from your markup. On a landing, an
  article, the home page or a product page `products` is `[]` — measured on the
  live `vz-page-data` of `/cyberwear` and `/neon-grid`, both of which carry the
  root and both of which are empty because neither is a section page;
- **loaded only when the document asks.** The trigger is the text of your
  document: `vz-for="… in products"` or `{{ products.… }}`. A document that never
  mentions the feed does not pay for it;
- **up to 48 items**, in the section's own order. No paging, no sorting and no
  "products of category N of my choosing" — the `vz-products` declaration is
  designed but not implemented;
- **a fixed field set**: `id`, `name`, `sku`, `price`, `price_text`, `old_price`,
  `old_price_text`, `url`, `preview`, `in_stock`, `promotion_name`. Gallery,
  attributes, variants and description are **not** in the feed; they live on
  `product`, which exists on a product page only.

Outside those boundaries a hand-built row still means names and prices typed into
the markup. Change a price in the catalogue and that row keeps showing the old one
until the document is re-uploaded.

**Say that to the owner before building.** An owner who hears "the prices on this
landing will stop updating" decides differently from one who does not.

What stays live regardless of the path: `vz-add-to-cart`, `vz-cart-count`,
`vz-fav`, `vz-variants`.

## 3. Objects and where they live

The markup is a **file**, not a field: three-step upload → `POST /html-documents`
→ a block section `kind: "html"` referencing the document id → the block into a
page zone.

| Level | What it means | Indexed |
|---|---|---|
| 1 | static, sanitised; keys expand server-side | yes |
| 2 | scripts inside a sandboxed iframe | no |
| 3 | authored inline in the real page DOM, scripts run | yes |

## 4. Two vocabularies

**Substitutions** print data: `{{ shop.name }}`, `{{ p.price_text }}`.
**Keys** are attributes that add behaviour or structure.

Machine list: `GET /docs/vz-keys.json`. Never invent a key — an unknown `vz-`
attribute is removed by the sanitiser.

Verified behaviours that are easy to get wrong:

- **nested `vz-for` works three levels deep** — `m in menu` → `c in m.children` →
  `g in c.children`. Expanded on the server; that is how a mega menu is built;
- **`vz-for` and `vz-if` on the same node** delete the node entirely. Put them on
  different nodes;
- **`vz-toggle` with no value** sets `vz-state="open"` on the node itself — the
  only way to build a click-dropdown without JavaScript:
  `.trigger[vz-state="open"] + .panel { … }`;
- **`vz-toggle="#id"`** sets the state on a target by selector — that is a modal;
- **`vz-gallery` reads `product.gallery` only** and ignores `preview`: an empty
  gallery draws nothing at all.

## 5. The wrapper, and the two ways to make a block yours

Every block on a page — a platform widget or your own markup — is drawn inside
the same wrapper, and the wrapper is configured by the same section object as the
widget: its keys sit **at the root of `props`** — beside the widget's own nest
(`html`, `listing`, `card`, …), never inside it and never beside `kind`. A
wrapper key written on the section instead is stored, returned on read and draws
nothing; `POST /docs/validate` calls that `ignored` and names the right place. So
making a block look unlike the platform is not a fight with the wrapper. It is one of two
deliberate moves, and §5.2 is the fork between them.

### 5.1 Anatomy: three nodes, and only one of them clips

```
div.vz-box.@container      THE BAND — the outer div of every block. Background,
 │                         hairlines, padding, block radius; this is the node
 │                         that becomes sticky. It does NOT clip by default.
 ├─ div.vz-bglayer         optional: present only when the band has a background
 │                         image or overlay — an absolute layer under content.
 └─ div.vz-inner           THE CONTENT BOX — the only other child. Caps the
     │                     content at the shop content width and centres it.
     └─ div.vz-radius      ONLY for kind:"html" at level 1 — the shadow host of
         │                 your document. ⚠️ THIS one clips, by default.
         └─ #shadow-root   your markup begins here
```

At **level 3** there is no shadow root and no `.vz-radius`: the host is
`div.vz-authored`, it clips nothing, and your markup is real page DOM.

What each node does to you, in the order it bites:

| Node | What it will do to your layout |
|---|---|
| `.vz-box` | gives the block platform air and a square band, and lets everything overflow it. Sticky, background, hairlines and the block radius all live here |
| `.vz-inner` | **caps your width.** A design that must reach the screen edge has to get through it: `props.blockWidth: "full"` removes the cap, `props.contentWidth` puts it back |
| `.vz-radius` | **cuts your dropdowns and modals off at the block edge** unless `props.overflow: "visible"` is set. The most expensive default in the wrapper |

Rewrite one of these with a class of your own and you are overriding exactly
that: the air and the band on `.vz-box`, the width cap and the centring on
`.vz-inner`, the clipping on `.vz-radius`.

### 5.2 Decide first: two ways to be unique

| If you want… | Take this path | What it costs |
|---|---|---|
| a **full re-layout** — your own grid, your own breakpoints, nothing of the platform left | **A. `props.wrapperClass`** — your class on the band, then your own CSS | somewhere to put that CSS: only a **level 3** document lives in the page's light DOM (§5.4) |
| to switch **one behaviour** off — the clipping, the width cap, the air, the stacking — and keep the rest | **B. the wrapper axes** — `props.overflow`, `props.blockWidth`, `props.paddingSides`, `props.position`, … | nothing. They are plain props, and `POST /docs/validate` answers `applied` / `ignored` before you write |
| to hang your own CSS or a script on one particular block | **`props.wrapperId`** — one id on the band, also usable as an `#anchor` | uniqueness across the page is yours to keep; the platform does not check it |

Not a path: **styling the platform's own class names.** See §5.5.

Prefer B wherever B can do it. An axis is read by the code that draws the block,
so it survives every change to the markup; a CSS rule of yours is only as stable
as the DOM it names.

Both keys work on the platform chrome too — `siteHeader`, `siteMenu` and
`siteFooter` draw their band themselves, but they read the same wrapper props.
That is how you keep the working header widget and still restyle it completely.

### 5.3 Path A, end to end

**1. Give the band your class.** The key sits at the root of `props`, beside the
widget's own nest — not inside its `writePath`, and not on the section:

```jsonc
{ "type": "text", "payload": { "v": 2, "kind": "html",
  "props": {
    "wrapperClass": "promo-hero grid-2",   // up to 3 names, separated by spaces
    "wrapperId": "prices",                 // one name: #anchors and your scripts
    "blockWidth": "full",
    "html": { "html_document_id": 10, "level": 1 }
  } } }
```

**2. Read what the page actually rendered.** Your names arrive after the
platform ones:

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

**3. Write CSS against your own name.** Order inside the attribute decides
nothing — specificity does. One class ties with one class, so against a platform
rule of the same weight use `.promo-hero.promo-hero` or the id:

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

`.vz-inner` appears there **as a child of your own class**, which is the one safe
way to reach it (§5.5).

**The rules on the names.** Every name is checked whole and dropped whole, so a
typo costs you that class and never a broken page: at most 3 classes, exactly one
id, pattern `^[a-zA-Z_][a-zA-Z0-9_-]*$`, at most 48 characters, and no name may
start with `vz-`. That prefix belongs to the platform — `vz-edge` in your list
would widen the block past `blockWidth` and `vz-vis-none` would hide it outright,
while you looked for the cause in your own CSS. A value carrying a quote, a
space-then-attribute or an angle bracket does not survive either: anything that
is not a whole valid identifier never reaches the attribute. The machine
statement of all of it, with the `file:line` that enforces it:

```bash
curl -s https://api.vizen.shop/docs/widgets \
  | jq '.wrapper.axes[] | select(.path == "props.wrapperClass")'
```

### 5.4 ⚠️ Where Path A's CSS has to live

A `<style>` inside a **level 1** document is inside the shadow root. It styles
your markup and **cannot see `.vz-box` at all**, because the wrapper is outside
the shadow boundary. Set `wrapperClass`, write level-1 CSS for it, and you get
exactly the "200 and nothing changed" this area exists to prevent.

From level 1 you can reach precisely one node above your own markup: `:host`,
which is the shadow host `div.vz-radius`. Nothing higher.

So Path A needs a **level 3** document (`"level": 3`), rendered as real page DOM,
cascade and all. One such block anywhere on the page carries the stylesheet for
every block on it:

```html
<style>
  .promo-hero > .vz-inner { max-width: none; display: grid; }
</style>
```

Level 3 is not open to every key: creating the document needs the owner or admin
role on the shop (otherwise `HTML_SCRIPT_ROLE_REQUIRED`), and the storefront
serves it where the server flag for authored markup is on — it is on in production. If
`POST /html-documents {level:3}` is refused, Path A is closed for this key: say
so to the owner and take Path B — do not fall back to level 1 silently.

### 5.5 The anti-path: do not style the platform's own class names

`.vz-box`, `.vz-inner`, `.vz-radius`, `.vz-edge`, `.vz-bglayer`, `.vz-fill`,
`.vz-ipad`, `.vz-vis-*` are internal names. They are not a contract, they carry
no version, and they have already moved: until 2026-08-14 the content box was
drawn separately by three widgets (cover, cta, inline html) and the section list
had a wrapper div of its own. Both are gone. CSS that named them broke silently
— which is the reason `props.wrapperClass` exists at all.

Three rules that keep your CSS alive:

- **always name your own class first**: `.promo-hero > .vz-inner`, never
  `.vz-inner` on its own. Scoped that way, a rename of the inner node costs you
  one selector on one block instead of a shop-wide restyle;
- **no `!important` against the wrapper.** The axes of §5.2 already win, and an
  `!important` takes the block away from the owner's panel for good;
- **never restyle `.vz-vis-*`.** Those are the device-visibility mechanism;
  overriding them shows a block on a device the owner switched it off on.

### 5.6 What the wrapper sets by default — read it, do not memorise it

Every default of both wrapper divs is published **generated from the storefront
code**, each with the `file:line` that sets it, in the `wrapper` section of
`GET /docs/widgets`. Print the whole thing:

```bash
# the wrapper divs: node, property, value, when it applies, and the line that sets it
curl -s https://api.vizen.shop/docs/widgets | jq -r '
  .wrapper.elements[] as $e | $e.defaults[]
  | [$e.selector, .property, .value, (.when // "-"), .anchor.at] | @tsv'

# the axes that change them: key, type, default, and the line
curl -s https://api.vizen.shop/docs/widgets | jq -r '
  .wrapper.axes[] | [.path, .type, (.default // "-"), .anchor.at] | @tsv'
```

**No default value is printed in this document, on purpose.** That table is
rebuilt from the code on every build and cannot go stale; a table typed into
prose can, and did. The hand-written one it replaced promised
`overflow: visible` for an html block while the shadow host was clipping,
a 16px band radius the band has never had, and a key `props.innerMax` that
nothing has ever read — the real one is `props.contentWidth`. If you are holding
the table from `/docs/widgets-area` §6, that is the hand-written one and three of
its rows are still wrong: the call above wins over it.

Three defaults decide most layouts, and all three surprise people:

- the **band never clips.** A shadow, a glow or a negative-margin overlap leaves
  the block whole; clipping is opt-in, through `props.overflow: "clip"`;
- the **band is square.** The rounding you see inside blocks belongs to elements
  *inside* them (`.vz-radius`), not to the band;
- the **shadow host of an html block clips by default.** For that one node
  "key absent" is not the same as `"visible"` — a dropdown or a modal needs
  `props.overflow: "visible"` written out explicitly.

For `kind: "html"` the platform also adds **no padding and no radius** of its
own: the look belongs to the author (`.wrapper.ownDefaults` in the same
reference). Want air? Set `props.paddingSides` explicitly.

### 5.7 Sticky: where it works and where it still does not

`props.position: "sticky"` — with `props.stickyTop` in px when something above is
already sticky — makes the **band** stick. The band is the only node with room to
travel: a `position: sticky` element inside your markup cannot move, because its
parent is exactly as tall as the block. Own markup can ask for the same by
putting `vz-sticky` on its root node.

Both were dead until 2026-08-19 — the branch lived in the wrapper and no caller
ever reached it. Measured in source after the fix:

| Surface | `props.position: "sticky"` | `vz-sticky` in the document |
|---|---|---|
| any block in a page zone (top, presentations, bottom) | works | works |
| any block in a **layout zone / chrome kit** — the custom-header case | works | works |
| a block nested inside a `columns` widget | works | **not asked there** — use `props.position` on that child |
| the chrome widgets `siteHeader`, `siteMenu`, `siteFooter` | **no** — the wrapper never runs for those kinds | **no** |
| the editor canvas in the admin panel | **no** — the preview does not ask; the live page does |

The platform header has its own switch and it works: `props.sticky: true` on the
`siteHeader` section (`GET /docs/widgets` → `siteHeader`).

⚠️ `props.overflow: "clip"` and sticky are mutually exclusive — a clipping
ancestor is not something a sticky descendant can stick to. Pick one per block.

⚠️ If you hold older notes prescribing `position: fixed` plus a hand-measured
spacer of the header's height, drop them. That workaround existed because the
sticky class used to land *inside* the wrapper. `position: fixed` is still
forbidden, and there is now nothing left for it to buy.

## 6. Images

Write a plain `<img src>` pointing at a shop file. The server rewrites it to the
resizer and adds `srcset` (640/1024/1600, webp). You do not build those paths.

**Set `sizes` yourself.** Only you know the slot width; without it the server
assumes full width and the browser takes the largest step — measured: 1600w for a
card rendered 268px wide. Your `sizes` is never overwritten; your own `srcset`
disables the rewrite entirely.

**Scope image fields are already resizer paths** — `product.preview_url` and
`product.gallery[].url` at the `card` step (1024), the `combo_items[]` and
`in_combos[]` previews at `thumb2x` (640), `products[].preview` at 640. Until
2026-08-20 they carried the absolute storage URL, and an author who wrote
`vz-src="product.preview_url"` shipped the original: measured 1 818 239 bytes
against 29 336 through `/w/640/webp`. Gate:
`storefront/test/image-slicing.test.mjs` ("поля scope товара уходят через
нарезчик, а не сырыми").

⚠️ **The server rewrite and the `vz-` keys are different mechanisms.** The
rewrite above reads the `src` of the **stored** document; `vz-src` is filled in at
render, after the rewrite has already run, so nothing rewrites it. That is safe
today only because the scope fields arrive pre-sliced — point `vz-src` at a URL of
your own and the browser gets exactly what you wrote.

## 7. Scripts

Level 3 renders inline. Scripts run on a direct hit **and** after an in-site link
click — the platform revives them, because markup inserted by the router would
otherwise contain inert `<script>` nodes.

- **never hide content behind a script.** Show it; let the script decorate. A
  landing whose cards started at `opacity: 0` looked empty when the script did
  not run;
- **do not rely on `DOMContentLoaded`** — after an in-site click the document is
  already loaded;
- remove global handlers when leaving; the platform does not clean up after you.

Never on cabinet, cart or checkout: that boundary is enforced, not advisory.

### 7.1 Bringing a finished page as a folder (HTML project, level 3)

> The full transfer path — inventory, dev key, layout folder, chrome groups,
> pages, templates, data re-binding, the runtime contract (shared cascade with
> the storefront's preflight, scripts re-run after soft navigation), the
> copy · remap · drop table — is `GET /docs/transfer` (skill `vizen-transfer`).
> This section is the per-widget mechanics.

**The widget is the folder.** A page that already exists as a folder —
`index.html` at the root, `styles.css`, `app.js`, `assets/…` in subfolders —
is moved **as is**: no path rewriting, no cutting into blocks. Every relative
path is taken from the folder root; on the site every file is served from the
widget root `/_html/{doc}/{release}/…` with the same nesting, and the site
page takes no part in addressing. There is **one wrapper — the site's** and no
other mode: the widget is a block inside the site page (header, footer and
other blocks around it), rendered without the platform box
(`props.html.boxed: true` brings the box back).

The normal input is **clean markup** — no `<!doctype>`, `<html>`, `<head>`,
`<body>`: just your sections plus `<link>/<style>/<script>` (format 1). A
whole document (format 2) is accepted too and converted on publish: head
bindings → `<div class="vz-head vz-head-{doc}" hidden>`, `<title>/<meta>/<base>`
dropped (title/description go to the page SEO when its fields are empty),
`<body …>` → `<div class="vz-body vz-body-{doc} …">`, and your
`body/html/:root` CSS selectors are rewritten to `.vz-body-{doc}`. The clean
result replaces `index.html` in the release (the original is not kept) and is
what the admin shows and edits; an edit in the admin is a new release. One
widget = one html page (`index.html`); an extra `.html` in the release is a
`page.extra` warning — a multi-page site is several widgets on several pages.

Paths in markup and CSS are rewritten to the widget root on output. A script
that builds paths itself reads the root from `window.VZ_ASSET_BASE` (the
platform puts `<script>window.VZ_ASSET_BASE="/_html/{doc}/{rid}/"</script>`
first in the fragment): `const base = window.VZ_ASSET_BASE || '';
img.src = base + 'assets/x.jpg'`. Locally, from the folder, `base` is empty.
A string path to a file in a `.js` without `VZ_ASSET_BASE` is a `js.assets`
warning — on the site it would resolve from the page URL and 404.

The document is the project; a **release** is an immutable snapshot of its
files keyed by relative path (sha256 dedup inside the shop; `index.html` is
always uploaded because the converter changes it), and exactly one release is
active. Rollback and activation move the pointer only. Full recipe with the
folder rules and the request bodies: `/docs/webcoding` §2а. The short form
(scopes `catalog:write` + `storage:write`, plus `catalog:read` to list; role
owner/admin in the shop):

1. `POST /html-documents {item:{name, level:3}}`.
2. `POST /html-documents/{id}/releases {files:[{path,sha256,size}], base:"active"|"none"}`
   — the server answers what to upload (`upload[]`) and what it already has
   (`reused[]`, `inherited[]`). Or one request with a ZIP:
   `POST /html-documents/{id}/releases/zip?publish=1` (multipart `file`) — a
   release only: the document from step 1 must exist, and step 5 is still yours.
3. `PUT upload_url` for each entry in `upload[]`.
4. `POST /html-documents/{id}/releases/{rid}/publish` — finalises uploads,
   validates, converts, switches the pointer atomically; answers `url` (for a
   dev key the page is on `<slug>--dev.<zone>`, visible under the owner's
   session), `path`, `embed_prefix` (= the widget root), `seo`;
   `422 RELEASE_INVALID` carries the report, `409 FILES_PENDING` lists what is
   still not uploaded.
5. Mount: an html section `{html_document_id, level:3}` on an **existing**
   page (`type='page'`; the slug comes from `GET /categories`); old html
   blocks on that page are replaced.
6. Open the `url` in a browser, take a screenshot, read the console: no JS
   errors, no 404s.

Folder rules the validator enforces: `index.html` at the root; every path
relative and inside the folder; `https://` only for external resources; no
server-side executables; no `serviceWorker.register`; ≤ 500 files, HTML ≤ 2 MB,
any file ≤ 50 MB. The one-command path is
`backend-3D/tools/html-transfer/transfer.mjs <folder> --page <slug>`
(Node 18+, token in `VIZEN_TOKEN`, `--api` for a stand).

### 7.2 Site-wide CSS and JS live in the LAYOUT, not in the header

⚠️ **Site-wide CSS belongs in the layout's folder, not in the header widget.**
Not because a level-3 header cannot share CSS — it is real page DOM and its
stylesheet does reach the whole page (measured: the product card of vezu-vezu
takes its font from the header's `global.css`) — but because the editor canvas
loads only the layout's `<head>`, the layout is linked before the first paint,
in the guarded zones (`/cart`, `/checkout`, `/account`…) it is the only
stylesheet that still arrives (a widget's relative `<link>` is dropped there
with the scripts), and a header unbound from a section takes its styles with it. A level-1 header
keeps its CSS inside its shadow root. So a stylesheet, a font or a script that
belongs to the whole site goes into the **layout's folder** — one folder that
lands on every page of the shop.

A layout is the group-container of `chrome.md` §3.6 (`group_type:"theme"`), and
it has a folder of its own: **the same release mechanism as §7.1**, addressed by
the layout instead of by a document id. `index.html` means something else here —
not content, but the **skeleton of the site**:

| In the folder | Where it goes |
|---|---|
| `<head>` of the skeleton | `meta`/`link`/`style` 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 |
| the content of `<body>` | the code before `</body>` of every page |
| `<title>`, `<base>`, `<meta http-equiv>`, `<link rel=manifest>` | stripped on publish: the title belongs to the page, the rest to the platform |
| any `*.css` | bare `html`, `body` and `:root` selectors are scoped to `.vz-theme-{doc}` — the class of the page **root**. So `body { font-family: … }` and `:root { --brand: … }` are the intended way to style the site. Compound selectors (`body.dark`, `body[data-x]`) are left alone on purpose: your script puts that class on the real `<body>` |
| `icon.png` (or `.jpg`/`.jpeg`/`.webp`) | `icon-32.png`, `icon-180.png`, `icon-192.png`, `icon-512.png` and `favicon.ico` are generated on publish; files you provide under those names are never overwritten, `icon.svg` is taken as is |
| everything else | served from `/_html/{doc}/{rid}/…`; scripts read the root from `window.VZ_THEME_BASE` |

The skeleton is parsed **once, on publish**, and stored assembled in the release
row — the storefront never parses HTML per request. SEO is not taken from it: one
`<title>` for a whole site would be a straight loss of search traffic.

Seven endpoints, same scopes and role as §7.1 (`catalog:write` + `storage:write`,
`catalog:read` to read; owner/admin in the shop). Paths carry no `/v1`:

| 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 yet → `{document_id: 0, files: []}` — **a GET never creates one** |
| `POST` | `/themes/{id}/site/releases` | draft release from a manifest `{base, files[{path,sha256,size}], delete[], note}` → `{release_id, upload[], reused[], inherited[]}` |
| `POST` | `/themes/{id}/site/releases/zip` | release from a ZIP (`?publish=1`, `?base=`) |
| `POST` | `/themes/{id}/site/releases/{rid}/publish` | validator → skeleton cleanup in place → favicons → the skeleton parsed into the release row → pointer switched. Answers `{release_id, kind:"theme", 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}` | releases with the active one / one release with its file map |
| `POST` | `/themes/{id}/site/rollback` | `{release_id?}` — the previous published one by default; pointer only (`catalog:write`) |

```bash
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": {…} }

node backend-3D/tools/html-transfer/transfer.mjs ./theme --theme 118   # the one-command path
```

Boundaries, and they are hard:

- **your code does not run in the guarded zones** — `/cart`, `/checkout`,
  `/account`, `/wishlist`, `/orders`, `/deals`, `/dashboard` and anything nested
  under them. There the layout's `head_html`/`foot_html` and
  `window.VZ_THEME_BASE` are not emitted, and an author-owned (level 3) header
  renders without its scripts. Styles, classes and markup stay, so the cart still
  looks like your site — but never plan a script for it;
- navigation inside the site is soft. After every navigation **and after the
  first load** a `CustomEvent('vz:navigate', {detail:{path}})` is dispatched on
  `document` — re-initialise from it, not from `DOMContentLoaded`. Moving to a
  page whose layout is a **different** one is a full reload, so two layouts never
  mix;
- limits are §7.1's (≤ 500 files, HTML ≤ 2 MB, any file ≤ 50 MB, ZIP ≤ 60 MB);
  a skeleton over 64 KB warns `theme.index_large` — it is added to every page;
- the validator drops the rules that make no sense for a wrapper
  (`head.stripped`, `page.noindex`, `img.nosize`, `file.unused` on icons) and
  treats `VZ_THEME_BASE` as an asset root like `VZ_ASSET_BASE`. A skeleton that
  does not parse is refused: `422 RELEASE_INVALID`, code `html.parse`;
- the folder's service document is not editable through `/html-documents/*`
  (`FailedPrecondition THEME_DOCUMENT_PROTECTED`), is absent from the document
  list and serves no `/content`. A `{id}` that is not a layout of yours —
  foreign, deleted or an ordinary block — is one and the same
  `404 THEME_NOT_FOUND`.

**A level-1 widget now gets the layout's CSS inside its shadow root.** Level 1
renders inside a shadow root, and a shadow root isolates in **both** directions:
only custom properties and inherited properties cross it by themselves. So a
widget on a site with a layout used to render "without the theme" while the same
markup at level 3 got it — measured on the stand, the wrapper's background inside
the shadow read `rgba(0,0,0,0)` against the page's `rgb(238,238,238)`.

Since 2026-09-13 the storefront puts the layout's stylesheets **into the shadow
root** as well and wraps your markup in `<div class="vz-theme-{doc} …">` there:

| Inside the shadow root | Why |
|---|---|
| the layout's `<link rel="stylesheet">` and inline `<style>` from the skeleton's `<head>` | they come structurally from the same parsed skeleton that draws the page `<head>` — one list, not a second copy |
| the wrapper `.vz-theme-{doc}` (plus the skeleton's `<body>` class) | the class on the host element is invisible from inside the shadow root (only `:host` sees it), so without a wrapper the rewritten `html`/`body`/`:root` rules would have nothing to match |
| **not** the layout's scripts (`head_html`/`foot_html`, `window.VZ_THEME_BASE`), icons, `preload`/`preconnect` | the shadow is drawn in the guarded zones too, where author code is forbidden; icons and loader hints style nothing |

Consequences, in the order you will meet them: the theme's fonts, CSS variables
and `body {}` / `:root {}` rules **do** apply to your level-1 markup; the
stylesheets are placed **before** your markup, so your own rules still win; the
href is the same one the page `<head>` uses (`/_html/{doc}/{rid}/…`), so the
browser takes it from cache rather than downloading it twice; and a shop with no
layout is byte-identical to before — the wrapper appears only when there are
theme styles to carry. Level 3 needs none of this: it is real page DOM and has
always been inside the page cascade.

Which layout a page uses, how to bind one to a section, and the system pages
(`cart`, `checkout`, `account`…) are in `chrome.md` §3.6. The step-by-step recipe
in Russian is `/docs/webcoding` §2б.

## 8. Silently ignored

| What | What actually happens |
|---|---|
| an unknown `vz-` attribute | removed by the sanitiser, no error |
| `vz-sticky` or `props.position` on `siteHeader` / `siteMenu` / `siteFooter` | those kinds draw their own band and the block wrapper never runs for them, so no sticky style is produced at all. The header has its own switch that does work: `props.sticky: true` (§5.7) |
| `vz-sticky` on an html block nested inside a `columns` widget | the column asks the child's `props` and never the document's key, so the key is read on the page but nothing acts on it. Put `props.position: "sticky"` on that child section (§5.7) |
| `props.wrapperClass` with a name that starts with a digit, a dash or `vz-` | the name is dropped whole and the rest of the list still applies — no error, and `class=` simply does not contain it (§5.3) |
| a substitution of a field that is not in scope | prints empty |

## 9. How this was verified

2026-08-19, live storefront `kiberpank.vizen.shop`: DOM chain measured in a real
browser; script revival checked twice (direct URL and in-site click); image steps
counted from network requests; scope roots read from `vz-page-data` on the page.

2026-08-20, re-measurement of the four claims this document used to get wrong —
by source and by live call, not from memory:

- **`products[]` in scope.** `storefront/src/widgets/html/scope.ts` declares the
  root and `productsOf()` fetches it live (`listProducts`, limit 48, `preview`
  through `imgUrl`); `data.ts` only asks for it when the document's text matches
  `/\bin\s+products\b|\{\{\s*products\b/`. Live: `GET /docs/vz-keys.json` lists
  `scope.products` with its eleven fields, and the deployed pages
  `/cyberwear`, `/neon-grid` carry the `products` root in `vz-page-data` (empty
  there — neither page is a section page). Gates: `scope-products.test.mjs`
  (7 scenarios, green);
- **the `overflow` axis.** `SectionHtmlInline.tsx` and `SectionHtml.tsx` both pass
  `clip={section.props?.overflow !== 'visible'}` into the shadow host. Gate:
  `wrapper-sticky-clip.test.mjs`, green;
- **scope images.** All five preview fields go through `imgUrl` in `scope.ts`.
  Gate: `image-slicing.test.mjs`, green;
- **`vz-sticky`.** Was still broken at the time of that pass — the wire was
  missing, not the branch — and was fixed the same day. See the wrapper wave
  below.

2026-08-19/20, the **wrapper wave** — what §5 now describes. Measured on the
assembled markup, not by reading the source, because the previous two passes were
fooled twice by reading it:

- **the wire for sticky.** `sectionSticky()` in `packages/sections/src/SectionBox.tsx`
  is now asked by the section list (`PageSections.tsx`) and by the nested blocks of
  the `columns` widget; the storefront's `stickyHtmlDocs` answer finally has a
  reader. The order of branches inside `wrapperFrameStyle()` changed with it:
  the background layer used to set `position: relative` **last** and overwrote
  `sticky`, so a band with a background never stuck;
- **the chrome exception is real and stays.** `SectionBox` returns the widget
  untouched for chrome kinds (`CHROME_KINDS`), so no wrapper style reaches
  `siteHeader` / `siteMenu` / `siteFooter`. Their band is drawn by
  `ChromeSectionBox`, which does read `wrapperClass` / `wrapperId` but takes no
  `sticky`. That is why §5.7 is a table and not a sentence;
- **own class and own id.** `packages/sections/src/wrapperClass.ts` is a
  whitelist, not a cleaner: a name has to match the identifier pattern whole or
  it is dropped. Checked on rendered markup — `wrapperClass: 'a" onmouseover=…'`
  produces `class="vz-box @container"` with neither `onmouseover` nor `alert(`
  anywhere in the output;
- **the defaults are no longer written by hand.** `wrapperDoc.ts` now calls the
  rendering code on empty props and pulls the rest out of the anchor CSS lines,
  and the generator turns each anchor into `file:line` — failing the build when
  the line is missing, ambiguous, or out of sync with the editor's CSS mirror.
  Gates: `storefront/test/wrapper-render.test.mjs` (renders real `PageSections`
  through `react-dom/server` and compares the reference with the markup) and
  `storefront/test/wrapper-sticky-clip.test.mjs`. 86 tests green;
- **level 1 CSS cannot reach the wrapper.** The document is inlined inside
  `<template shadowrootmode="open">`, so its `<style>` is shadow-scoped by
  construction; `<style>` itself survives the level-1 sanitiser
  (`internal/api/catalog/html_sanitize.go` allows it explicitly). That is the
  §5.4 warning, and it is a property of the shadow boundary, not a bug.

## 10. Negations in this document (rule §3.9)

Every "does not", "never", "empty", "not implemented" in the text above is listed
here with what keeps it true. A negation with neither a gate nor a line in this
table is the class of sentence that turned five statements of this document into
lies in one week — three of them fixed by the very wave that shipped the feature
they described.

| The claim | Kept true by |
|---|---|
| an unknown `vz-` attribute is removed, no error (§4, §8) | gate `internal/api/catalog/html_sanitize_test.go` · `TestHTMLLevel1PolicyStripsUnknownAndDataAttrs` |
| `vz-for` + `vz-if` on the same node delete the node (§4) | after-wave pass — no gate |
| `vz-gallery` ignores `preview`; an empty gallery draws nothing (§4) | after-wave pass — no gate |
| `products` is `[]` outside a section page, and `[]` until the document asks (§2) | gate `storefront/test/scope-products.test.mjs` for the "only when asked" half; the "section page only" half — after-wave pass |
| the `vz-products` declaration is not implemented (§2) | after-wave pass — closes when the checklist's П5 lands, and this row must fall with it |
| the feed carries no gallery, attributes, variants (§2) | gate `scope-products.test.mjs` + `vz-reference.json` field list (both read the same eleven names) |
| the shadow host clips when `props.overflow` is unset (§5.1, §5.6) | gate `storefront/test/wrapper-sticky-clip.test.mjs` — the predicate `htmlHostClips()` is one function called by the storefront, the canvas AND the reference, so there is no second copy to drift |
| the band itself never clips, and is square (§5.6) | gate `storefront/test/wrapper-render.test.mjs` · "обёртка без настроек: никаких инлайновых стилей" + "справочник дефолтов совпадает с тем, что реально стоит на обёртке" — both read the rendered markup, not the source |
| `kind: "html"` gets no padding and no radius (§5.6) | generated: `.wrapper.ownDefaults` in `/docs/widgets` is built from the widget's own manifest, so it cannot disagree with the renderer |
| a name starting with a digit, a dash or `vz-` never reaches `class=` (§5.3, §8) | gate `storefront/test/wrapper-render.test.mjs` · "лимиты имени соблюдаются" + "инъекция через свой класс ломается" |
| level-1 CSS cannot see `.vz-box` (§5.4) | property of the shadow boundary, not of our code — the document is inlined inside `<template shadowrootmode>`. Nothing to gate; it falls only if level 1 stops using Shadow DOM |
| `props.innerMax` does not exist (§5.6) | gate `storefront/test/wrapper-render.test.mjs` · "оси, которых нет в коде, в справочнике не живут" — the key was published for weeks and read by nobody |
| no default VALUE is stated as current in §5 (§5.6) | the rule itself. Two numbers do appear in §5.6 and both are quoted as claims of the hand-written table this section replaced — that is, as things that were **wrong**. Everything stated as current comes from the `curl`. A number stated as a current default is the regression |
| your `sizes` is never overwritten (§6) | gate `internal/api/catalog/html_images_test.go` · `TestRewriteHTMLImagesKeepsAuthorSizes` |
| `vz-src` is not rewritten by the server (§6) | after-wave pass — it is a consequence of the order of the two mechanisms, and nothing tests that order |
| sticky works on page zones, layout zones and `columns` children, and NOT on the three chrome kinds or in the editor canvas (§5.7, §8) | gate `storefront/test/wrapper-render.test.mjs` · "липкость доезжает от props до атрибута", "своя вёрстка просит липкость ключом vz-sticky", "фон обёртки больше не затирает липкость" — these read the `style` attribute of rendered markup. ⚠️ The older gate `wrapper-sticky-clip.test.mjs` stayed green through the entire life of the previous defect because it checks the *shape* of the source; the surfaces where sticky is still not asked are held by this table alone, so re-measure them by search |
| scripts never run on cabinet, cart or checkout (§7) | after-wave pass — the boundary is enforced in the storefront, not gated here |
| a substitution of a field outside scope prints empty (§8) | after-wave pass — no gate |

**How the pass is run.** Grep this file for `never`, `not`, `no `, `empty`,
`cannot`, «нет», «нельзя», and check each hit against the code — an hour per 600
lines, four findings the first time it was done.
