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

**Status:** current · **Verified:** 2026-08-20, live calls + source measurements;
§7.2 — 2026-09-12, against the code (handlers, validator, storefront); layout styles in the
level-1 shadow — 2026-09-13, against the storefront code + a headless Chrome run
**Owner:** Vizen API documentation · **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 of **any section, on any page**, any design | own markup over the `products[]` scope, the section named by `productsCategoryId` | live prices — ≤ 48 items (or groups), base fields plus chosen attributes and group tiles on request, one section per block |
| a **hand-picked** row in your order | own markup with `productsIds` (§2 below) | live data; up to 48 ids, one member per family in collapse mode |
| a product row with the platform look | platform `productListing` (`widgets.md`) | props configure the standard look; layout CSS and declared hooks support intentional overrides (§5) |
| 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) | CSS in the layout folder or a level-3 document (§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 |
| a lightbox, a product window, a cart button, search — **ready behaviour without your own JavaScript** | a module from `/docs/modules`: copy its snippet into your markup | the look is your CSS, the behaviour is the platform's — the same as in its own blocks |

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

Feed boundaries (an explicit `productsIds` selection overrides the section and its order):

- **the feed is ONE section — the one the BLOCK names.** Default: the section
  the page itself is, so a landing, an article, the home page and a product page
  have none and `products` is `[]` there. Name a section on the block and the
  same markup runs anywhere: `props.html.productsCategoryId` on an html widget,
  `props.component.productsCategoryId` on a component insertion. A section of
  **another shop** yields an empty feed, never foreign goods — the call is
  always scoped to this shop;
- **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;
- **48 items per page**, in the section's own order unless `productsIds` sets an explicit order. The page's
  own section pages with `?page=N` — the parameter the platform listing uses, so
  canonical and the "page N" title already follow it — and `products_page`
  carries the navigation: `page`, `pages`, `total`, `per_page`, `prev_url`,
  `next_url` and a ready `links[]` row (`number`, `url`, `current`, `gap` for
  "…"). A block that names another section gets its first page only. One
  section per block: two sections in one row means two blocks;
- **base fields**: `id`, `name`, `sku`, `price`, `price_text`, `old_price`,
  `old_price_text`, `url`, `preview`, `preview_srcset`, `in_stock`, `promotion_name`,
  `discount_percent`, `discount_percent_text`, plus the combo fields below.
  **On request of the block**: chosen attributes (`productsAttributes`) and one
  tile per product group with switchable members (`productsMode: "collapse"`) —
  see «Attributes on a tile» and «One tile per group» below. Gallery,
  variants inside one product and description are **not** in the feed; they live
  on `product`, which exists on a product page only.

**Recipe — live products on a landing page.** The landing is no section, so the
block says which one to show:

```jsonc
{ "type": "text", "payload": { "v": 2, "kind": "html",
  "props": { "html": { "html_document_id": 107, "level": 1,
                       "productsCategoryId": 625 } } } }
```

```html
<ul><li vz-for="p in products">{{ p.name }} — {{ p.price_text }}</li></ul>
```

Two blocks on one page may name two different sections; feeds with the same section and normalized profile (mode, attributes, ids)
are shared; different profiles get separate feeds.

**Discount percent.** `discount_percent` is the cut as a whole number and
`discount_percent_text` the ready badge (`"10%"`); both are `0` and `""` when
there is no old price or the cut is under one per cent. It is derived from the
two prices the server already computed, not from a second discount engine, and
it is rounded **down** — the page never promises more than the checkout takes.
The same pair sits on `product`, so a tile and a card show the same number.

**Combo in the feed.** `p.combo` is the set in which THIS product is cheapest:
`name`, `url`, `preview_url`, `item_price` / `item_regular_price` (the in-set
price against the ordinary one), `combo_price` and `benefit`, each with a
`_text` twin. It is **absent** whenever there is nothing honest to say — no set,
no benefit, or the in-set price is not lower — so `vz-if="p.combo"` is the whole
condition. `p.combos_count` is how many sets the product is in. For a product
that IS a set, `p.combo_items[]` carries its composition (`name`, `url`,
`preview_url`, `quantity`, `price` = the price inside the set, `regular_price` =
the ordinary one, both with `_text`) and `p.benefit_text` is what the set saves.

On a set tile strike either `components_total_text` or `old_price_text`: a set is
priced **by its composition** everywhere, so both carry the same number on the
tile and on the set's own page. One difference: `old_price` is empty when there
is nothing to strike (no component is cheaper inside the set), while
`components_total` is always the sum — use it for a "N ₽ separately" line beside
the benefit.

Both halves are one extra batch request (`GET /products/combo`), made once per
page and **only when your markup mentions `.combo` or `combo_items`** — a
document that does not, pays nothing. Calling it yourself: ids are a **repeated**
parameter, not a comma-separated list —
`GET /products/combo?company_id=19&ids=2606&ids=2607` (a comma answers `400`,
naming `ids`). The answer is **sparse**: a product that is in no set is left out
rather than returned empty, so match the rows by `product_id` instead of by
position. The numbers come from the same code that
builds the product card, so a tile and the product's own page cannot disagree.
Do not recompute them: the in-set price is derived from the BASE price, and a
second calculation would drift from the cart.

**Recipe — a "cheaper in a set" ribbon on a list tile:**

```html
<article vz-for="p in products">
  <img vz-src="p.preview" alt="">
  <span vz-if="p.discount_percent" class="badge">{{ p.discount_percent_text }}</span>
  <a vz-if="p.combo" vz-href="p.combo.url" class="ribbon">
    В КОМБО ВЫГОДНЕЕ {{ p.combo.item_price_text }}
    <s>{{ p.combo.item_regular_price_text }}</s>
  </a>
  <h3>{{ p.name }}</h3><span>{{ p.price_text }}</span>
</article>
```

**Recipe — a set tile with its composition** (a section of `kind=combo`
products):

```html
<article vz-for="p in products">
  <img vz-src="p.preview" alt=""><h3>{{ p.name }}</h3>
  <p>{{ p.price_text }} <s>{{ p.components_total_text }}</s></p>
  <div vz-for="c in p.combo_items" class="part">
    <a vz-href="c.url"><img vz-src="c.preview_url" alt=""> {{ c.name }}</a>
    <span>{{ c.price_text }} <s>{{ c.regular_price_text }}</s></span>
  </div>
  <a vz-href="p.url">Показать комбо</a>
</article>
```

**Attributes on a tile.** The block lists the attribute codes it needs, in
order — `productsAttributes` next to `productsCategoryId` (`props.html.…` or
`props.component.…`), up to 32:

```jsonc
"props": { "component": { "ref": 89, "productsCategoryId": 625,
                          "productsAttributes": ["bed-size", "material"] } }
```

Every item then carries `p.attrs.<code>` — `{code, name, value, values, options}`,
where `value` is the ready text (units, option labels) — and `p.attributes[]` in
that order. One component can show different attributes in different sections:
the list belongs to the block, not to the markup. Codes may contain hyphens:

```html
<span vz-if="p.attrs.bed-size">{{ p.attrs.bed-size.value }}</span>
<dl><dt vz-for="a in p.attributes">{{ a.name }}: {{ a.value }}</dt></dl>
```

Only attributes a buyer may see **in lists** arrive; hidden, empty and unknown
codes are absent — guard with `vz-if`. Configure buyer access for selected
attributes **and group axes** before authoring; defaults, the access PUT and its
filter-panel side effect are canonical in `/docs/catalogue` §8.3.
A malformed code is dropped and reported in render issues instead of emptying the feed.

**One tile per group (size × colour in place).** `productsMode: "collapse"` turns
the feed into one tile per **product group** (linked products with axes,
`/product-groups`). Paging and the total count go by groups, so a page boundary
never splits a family. Each tile carries three things (the complete field lists
are in `/docs/vz-keys.json` → `scope.products`; this page explains how to use them):

- `p.members[]` — a pane per member **with a full card** (everything a plain
  tile has: own price, stock, picture, url, `combo`, `attrs`); `selected` marks
  the member that stands as the tile. A product outside any group has one
  member — itself;
- `m.variants[]` — the switch chips **for that member**. Changing one axis keeps
  the others; a combination that does not exist is `available: false` — never a
  substitute SKU. `has_combo` on a chip means some member with that value has a
  profitable set (a size dot that stays on another colour); the set's own price
  is only on the member that is in it, `m.combo`;
  `has_combo` is populated only when the document requests combo quotes
  (mention `m.combo`; merely reading `has_combo` does not load them —
  `/docs/vz-keys.json` → `scope.products`);
- `p.group.all[]` — **every** member with a link, so any variant can be opened,
  even one no single-axis chip reaches.

The server renders a pane per member; the keys `vz-panes`, `vz-pane`, `vz-pick`
and `vz-hidden` make a click show the member's pane in place — nothing is
recalculated in the browser. A chip is a real link: without JavaScript, on a
Ctrl/Cmd-click, or for a member without a pane it opens the member's page.

```html
<article class="card" vz-for="p in products" vz-panes>
  <div vz-for="m in p.members" vz-pane="{{ m.id }}" vz-hidden="!m.selected">
    <a vz-href="m.url"><img vz-src="m.preview" srcset="{{ m.preview_srcset }}" alt="{{ m.name }}" loading="lazy"></a>
    <a vz-href="m.url">{{ m.name }}</a>
    <b>{{ m.price_text }}</b>
    <a vz-if="m.combo" vz-href="m.combo.url">В комбо {{ m.combo.item_price_text }}</a>
    <div vz-for="ax in m.variants">
      <span vz-for="o in ax.options">
        <a vz-if="o.available" vz-href="o.url" vz-pick="{{ o.member_id }}" aria-current="{{ o.current }}">{{ o.label }}</a>
        <span vz-if="!o.available" aria-disabled="true">{{ o.label }}</span>
      </span>
    </div>
    <button vz-add-to-cart="{{ m.id }}">В корзину</button>
    <button vz-fav="{{ m.id }}">♡</button>
  </div>
  <details vz-if="p.group">
    <summary>Все варианты</summary>
    <a vz-for="v in p.group.all" vz-href="v.url" vz-pick="{{ v.member_id }}">{{ v.label }}</a>
  </details>
</article>
```

Style the selected chip by `a[aria-current="true"]` (sanitising rules: §4). A hidden pane stays hidden over your own `display:`
(the platform adds `[vz-pane][hidden]{display:none!important}`); for your own
nodes with `vz-hidden` add `[hidden]{display:none}`. Members do not obey the
section's filters (a family may span sections) — `p.members` is the family, not
more tiles. Full cards: up to 16 per group and 200 per page; a member beyond
that has no pane — chips and `p.group.all[]` entries pointing to it carry
`has_pane: false` and open it by link. `p.members` exists only with
`productsMode: "collapse"`. Both settings answer as a buyer for everyone, the
owner included (`catalogue.md` §4.1).

**A pick list in your order — `productsIds`.** Up to 48 product ids next to
`ref`: the feed is exactly these products in this order instead of a section
(`productsCategoryId` is ignored, one page). With `productsMode: "collapse"` list
**one member per group** — it becomes the tile and its pane is the one shown
first, so the list sets both the order of the families and the default variant;
the rest of each family still comes as `p.members`. The same setting picks the
sets for a combo strip (`p.combo_items`). Drafts, hidden and foreign products are
simply absent; invalid ids are dropped and reported in the render issues.

```jsonc
"props": { "component": { "ref": 42, "params": {},
  "productsMode": "collapse", "productsAttributes": ["bed_size", "bed_color"],
  "productsIds": [2503, 2509, 2498, 2501, 2515] } }
```

For level-1 CSS and allowed attributes, see §4 below.

Only names and prices typed literally into the markup are a snapshot. 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`.

**`vz-panes` / `vz-pick` work at level 1 inside the shadow root**, as well as
in page DOM; the click runtime uses the composed event path. This was also
verified in the owner’s TOPLOFT production acceptance (2026-10-25).

## 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` sees the outer scope** (for example `p` inside `m in p.members`) and 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-if` runs before `vz-for` binds its variable** on the same node. A
  condition using that new variable sees no value; put it on a child node;
- **`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. Zoom: add `vz-lightbox` to the same root
  (`/docs/modules` §4).

**Conditions are paths, not expressions.** `vz-if="path"` or `vz-if="!path"`:
no comparisons, arithmetic or boolean operators. To show an axis with at least
two options, use `vz-if="ax.options.1"`. To distinguish a textured axis from a
size axis when that is how the shop's data is modelled, test
`vz-if="ax.options.0.image_url"` (missing textures need a text fallback).

```html
<div vz-for="ax in m.variants">
  <div vz-if="ax.options.1">
    <span vz-if="ax.options.0.image_url">Текстуры</span>
    <span vz-if="!ax.options.0.image_url">Размеры</span>
  </div>
</div>
```

**Level-1 sanitising.** All author `data-*` attributes are removed: a selector
`[data-colour]` cannot match. Use classes and the allowed accessibility
attributes (`aria-current`, `aria-disabled`, etc.; not an arbitrary `aria-*`
namespace). Registered keys `vz-pane` and `vz-pick` survive; selected chips use
`[aria-current="true"]`. `<style>` survives, while a relative
`<link rel="stylesheet" href="style.css">` is removed. Inline the CSS in
`<style>` for a self-contained level-1 release. This applies to HTML documents
and components alike; level 3 has a different contract (`/docs/transfer`).

### 4.0 Live data without your own script

A header, a mini-cart or an account menu of your own needs **no polling and no
counters of your own** — the platform keeps these nodes current, on every page,
guarded zones included:

| Put this | You get |
|---|---|
| `<span vz-cart-count></span>` | items in the cart; **empty at zero** — hide with `[vz-cart-count]:empty{display:none}` |
| `<span vz-fav-count></span>` | items in favourites, same rule |
| `<div vz-auth>…</div>` | `vz-state="in"` or `"out"` once the session is known (no attribute before — no "Sign in" flash) |
| `<span vz-user-initial></span>` | the signed-in shopper's initial; empty for a guest |
| `<div vz-search>Search</div>` | the input **and** the same live suggestions as the platform header (products with price and picture, sections); Enter opens results |

Account links of the platform header: `/wishlist` (favourites), `/orders`
(orders), `/account` (profile, sign-in and sign-out).

When you do need code (a badge animation, your own menu), **listen, do not
poll** — events on `document`:

- `vz:navigate` `{path}` — the page changed (and on the first load);
- `vz:cart` `{count}`, `vz:fav` `{count}` — the cart or favourites changed;
- `vz:auth` `{signedIn, initial}` — the session became known or changed.

Each fires on change **and once right after every `vz:navigate`** with the
current value, so there is no separate "on load" branch:

```js
document.addEventListener('vz:fav', e => badge.classList.toggle('on', e.detail.count > 0))
```

Do not build resizer URLs (`/w/…`) or call the catalogue API from the page for
things listed here — that is exactly what these keys replace.

**Links need nothing either.** Every same-site `<a href>` of your markup — header,
footer, body, breadcrumbs — opens **without a page reload**, like any link of the
site: the header stays put, the page body is swapped, then `vz:navigate` fires
and your scripts re-initialise (write them so a second run is harmless). This is
the standard of Turbo, SvelteKit, Nuxt and `next/link`. The browser still loads
the page in full for: a modifier or middle click, `target`, `download`, another
site, `tel:`/`mailto:`, files (`/files/…`, `.pdf`, `.xml`), an anchor on the same
page, the guarded zones and a different layout. To force a full load yourself,
put `vz-reload` on the link or its container (like `data-turbo="false"`). Links
inside `vz-variants` keep the scroll position.

**"You are here" needs no script.** The server marks same-site links of your
markup: `aria-current="page"` on a link to this page, `aria-current="true"` on a
link to a section above it (`/catalog/` on `/catalog/beds`). Style
`a[aria-current]` in the layout CSS — the highlight is right in the first frame,
while a script-set class would blink after every navigation.

### 4.1 Open a product in a window (quick view)

Moved to `/docs/modules` §3: the opener snippets (`vz-modal`, `href="modal:…"`,
the level 2 message). The `modal:` parameters and the window's behaviour are in
`/docs/webcoding#14-3-modal-window-from-html-a-product-or-a-ready-design-group`.

## 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 and breakpoints | **A. `props.wrapperClass`** — your class on the band, then your own CSS | put shared CSS in the layout folder or a level-3 document (§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 |

For intentional standard-template styling, use the declared hooks (§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 **as a child of your own class**, which limits this override
to the chosen block. Broader template styling is also available (§5.5).

**The rules on `props.wrapperClass` / `props.wrapperId`.** These are checked API
fields, not a naming rule for classes inside your own HTML. Choose your own
name (`atelier`, `brand-shell`, `acme-card`); no Vizen prefix is required.
Every name in these two fields 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

CSS targeting the outer wrapper **must live outside the level-1 shadow root**.
Use the layout folder or a level-3 document; local level-1 selectors cannot
cross that boundary. Level 3 requires owner/admin and the relevant token rights.
**Ask before you write:** `GET /v1/account/token` answers
`capabilities.html_level3_allowed` — `true` means your key may create a level-3
document, `false` means `POST /html-documents` with `level: 3` will answer
`HTML_SCRIPT_ROLE_REQUIRED`. That is a role in the shop, not a scope, so no
other passport field implies it — `publish_allowed` in particular is about the
dev contour and says nothing about levels.
An access refusal is not solved by silently switching to level 1.
The single surface/cascade matrix is §7.2.

### 5.5 Stable names do not isolate your overrides

`/docs/vz-keys.json` → `markup_contract` promises stable names and states their
availability in `where`; it does not guarantee the safety of arbitrary CSS.
Use your own root or `props.wrapperClass` for a local change, for example
`.promo-hero > .vz-inner`. A global `.vz-inner` rule reaches every matching
column; overriding `.vz-radius` can override the radius chosen in the panel.
If a site-wide restyle is intended, check all affected templates and pages.
Ordinary author classes have no required Vizen prefix. The checked wrapper
fields (§5.3) and runtime `vz-*` attributes have separate contracts.
The generated list is authoritative; do not infer stability from a prefix.

### 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 |

**There is a third axis in this table and it is the document's LEVEL.** A
level-3 document is rendered *without* the platform box by default — that is the
point of level 3, your file in the real page DOM — and with no box there is no
band to stick, no `wrapperClass`, no `container`, no `overflow`. The markup says
so: `<div class="vz-authored-block">` instead of `<div class="vz-box …">`. The
props are still stored and read back, and nothing refuses them.

Ask for the box back with **`props.html.boxed: true`**, and every row above
applies again — including in a chrome slot, where a custom header wants exactly
that combination: level 3 for its own script, plus a sticky band. Verified
2026-09-20 in the storefront source: a block in a chrome slot goes through the
same section renderer as a block in the page body, and the only thing deciding
between the two markups is `level === 3 && !boxed`. Levels 1 and 2 are boxed
either way. In a chrome slot prefer the explicit `props.position: "sticky"` axis
over the in-markup `vz-sticky` key — the zone does not always build the context
the key needs.

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.

For a header that keeps its place in the flow, prefer sticky. Absolute/fixed
positioning and overflow are separate CSS mechanisms. Before promising a
viewport-fixed element, use the surface matrix and browser checks in §7.2.

## 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.

**The server adds `loading="lazy"`.** Good for everything below the fold and
wrong for the one image that *is* the first screen. Your own value is kept, so
write `loading="eager" fetchpriority="high"` on the hero image yourself —
exactly like `sizes`, a sensible default that you have to override knowingly.

**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.

Feed resizer addresses such as `/w/…` are **relative to the shop host**. Test
on `store.storefront_url`, not on the API host or a local `file:` page.
For small slots, chip options in `m.variants[].options[]` and selected attribute
options in `p.attrs.<code>.options[]` expose `image_thumb` + `image_srcset`;
combo composition exposes `p.combo_items[].preview_srcset` alongside `preview_url`.
Use those fields rather than the original `image_url`:

```html
<img vz-src="o.image_thumb" srcset="{{ o.image_srcset }}" sizes="40px" alt="">
<img vz-src="c.preview_url" srcset="{{ c.preview_srcset }}" sizes="44px" alt="">
```

These `sizes` values are examples: match the actual CSS slot at every breakpoint.
Too large a hint fetches an unnecessary step. Measure visible slots and
`currentSrc` at DPR 1/2/3, including after switching panes; `/docs/troubleshooting` §2 has the measurement snippet. Browser access to level-1
content: `/docs/troubleshooting` §2.

**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 товара уходят через
нарезчик, а не сырыми").

**Ready `srcset` for data-bound images.** A value bound through `vz-src` is
substituted at render time, after the server rewrite has run, so it gets no
`srcset`. The steps come with the data instead: `products[].preview_srcset`
(320/640/1024/1600) and `product.gallery[].srcset` (640/1024/1600/2560) with `w`
descriptors, `gallery[].thumb_srcset` (128/320/640) for a thumbnail strip, plus
`gallery[].zoom_url` (2560) for a "look closer" window — or let the platform
lightbox take the large frame for you (`/docs/modules` §2). A `srcset` made of one
`{{ … }}` is a placeholder, not a file: the release check does not look for it. Write
`<img vz-src="p.preview" srcset="{{ p.preview_srcset }}" sizes="…">`. An empty
value means the picture cannot be resized (svg, a foreign host). The resizer does
not upscale: a step above the original returns the original.

⚠️ **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,
or, kept whole and separate, a site folder (`/docs/file-storage`).

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` §2a. 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. **The files are attached and served** — this half is checkable over the API
   alone, so check it: `GET /html-documents/{id}/releases/{rid}` lists the
   release's files, `GET …/releases/{rid}/file?path=<p>` returns one, and the
   page HTML must reference them under `/_html/{doc}/{rid}/`. A 200 with the
   right size closes "no 404s" for everything you shipped.
7. **The script actually ran** — this half needs a browser, and no endpoint can
   answer it: open the `url`, take a screenshot, read the console, no JS errors.
   With no browser, say so plainly rather than implying you checked — and write
   the block so it is still meaningful without its script (§7: never hide
   content behind a script).

Reading back: `GET /html-documents/{id}/releases/{rid}` answers the release with
its file map, and `GET /html-documents/{id}/releases/{rid}/file?path=style.css`
one source file — `{path, mime, size, sha256, text}`, text types only and ≤ 2 MB
(`404 FILE_NOT_FOUND`, `415 FILE_NOT_TEXT`, `413 FILE_TOO_LARGE`). That is the
read half of an in-place edit: read → change → `POST …/releases {base:"active",
files:[…]}` → publish.

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.

If you have the platform repository, all of the above is one command:
`node backend-3D/tools/html-transfer/transfer.mjs <folder> --page <slug>`
(Node 18+, token in `VIZEN_TOKEN`, `--api` for a stand). With only a token and
the API — the ordinary case — use the two calls above.

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

**Layout styles reach inside a level-1 shadow root.** Since 2026-09-13 the
stylesheets and fonts of the layout, and its theme class, are injected into the
shadow of every level-1 block and component — so a level-1 component inherits
the site's design system without one duplicated line, and the main objection to
level 1 ("nothing of the site is visible inside the shadow") no longer holds.
The details and the surface matrix are at the end of this section.

Shared CSS belongs in the layout folder so unbinding a header does not remove
it. The table below states which surfaces receive it. Local level-3 styles also
participate in the page cascade; local level-1 styles stay in their shadow root.

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.

**One design system across the layout.** Linked CSS reaches pages using the
layout. Bare `html`/`body`/`:root` are rewritten to its scope class; ordinary
selectors are not automatically prefixed. Choose your own root name:

```html
<!-- layout/index.html -->
<head><link rel="stylesheet" href="site.css"></head>
<body class="atelier"></body>
```

```css
/* layout/site.css */
@font-face { font-family: Atelier; src: url("fonts/brand.woff2") format("woff2"); }
.atelier { --brand: #172520; font-family: Atelier, sans-serif; }
.atelier .campaign-card { background: var(--brand); color: white; }
/* Intentional override: wrapperClass="atelier-header" on the chosen header */
.atelier-header > .vz-inner { max-width: 72rem; }
```

Body classes land on a **div**: `.atelier` matches it, `body.atelier` does not.
Prefer your own root for independent styling; broader rules deliberately affect
standard templates too (§5.5). No fixed stylesheet order relative to the
platform bundle is promised: inspect precedence/layers, specificity, inline
styles and computed styles. Inside a shadow root the injected theme styles
precede local markup; later rules win only at equal cascade priority.

| Surface | Layout CSS / boundaries |
|---|---|
| page, standard blocks, chrome, level-3 markup | shared page cascade; relative fonts/images resolve from the layout release |
| level-1 storefront | links and inline styles from the layout head are injected inside the shadow, under its scope/body classes; local selectors cannot style the outer wrapper |
| level-2 iframe | separate document; parent CSS does not enter; required styles must be linked explicitly |
| guarded system pages | layout CSS/classes and static markup remain; author scripts and widget-relative stylesheet links are removed |
| editor | level-3 preview receives the layout head; level-1 preview has base hooks but not the same theme injection |

For hook availability use the generated `markup_contract.where`: page only —
`.vz-box`, `.vz-radius`, `data-vz-kind`, `data-vz-block`, `data-vz-kit`,
`data-vz-idx`; shadow — `.vz-pad`, `--vz-gutter`; both — `.vz-inner`,
`--vz-content-box`. This is availability, not permission to override every
matching node safely. The shadow helpers allow alignment with the platform
without inventing a required naming scheme for your own design.

**Positioning and container queries.** The platform declares these query
containers. `container-type` alone is not proof of a fixed-position containing
block: [CSS Conditional 5](https://drafts.csswg.org/css-conditional-5/#container-type)
removed its layout-containment side effect.
In a local Chrome 152 probe, fixed stayed viewport-relative for `inline-size`,
while explicit `contain:layout`/`paint` or `transform` anchored it to the ancestor.
Older engines may differ; a real shop/layout must still be checked in its target
browsers. This probe is not acceptance of the pending dev-store test.

| Surface | Platform query container / consequence |
|---|---|
| boxed block (level 1 or `boxed`) | `.vz-box` has `container-type:inline-size`; inspect this and all ancestors for explicit containment/transform |
| unboxed block in a page zone, no level-3 HTML escape | `main.vz-visibility-scope` has `container-type:inline-size` |
| page with an unboxed level-3 **HTML** block | `main.vz-has-authored` switches to `container-type:normal`; it does not remove containers/transform on descendants |
| chrome slot | `ChromeNav` keeps its own `.vz-visibility-scope` query container; the main escape does not change chrome |
| system-page body | the standard system main has no visibility query container; inspect any nested wrappers |
| level-2 iframe | fixed uses the iframe viewport, not the parent page viewport |

The escape is detected from `kind:html` with `props.html.level:3` and no `boxed`;
a component alone does not trigger it. **Side effect:** range visibility rules
that depended on the main query container lose it. Nested query containers can
still supply a range, and `.vz-vis-none` remains hidden. Check neighbouring
blocks after adding/removing authored HTML; do not disable visibility globally
to fix positioning. `100vw`/`100dvh` are viewport units, not container units.
Measure fixed `getBoundingClientRect()` before/after scroll and check overlap.

Inside the layout use relative paths such as `fonts/brand.woff2`. `/_site/`
selects the **shop's active layout**, not a page-specific override. It is useful
for shared/external links; file addressing and CSS selector scope are separate.

Ten method/path operations, 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 |
| `GET` | `/themes/{id}/site/releases/{rid}/file?path=` | one source file of that release: `{path, mime, size, sha256, text}`; text only, ≤ 2 MB, else `415 FILE_NOT_TEXT` / `413 FILE_TOO_LARGE` |
| `POST` | `/themes/{id}/site/rollback` | `{release_id?}` — the previous published one by default; pointer only (`catalog:write`) |
| `DELETE` | `/themes/{id}/site/releases/{rid}` (and `/html-documents/{id}/releases/{rid}`) | remove an old version: `{deleted, release_id, files_deleted}`; the version active on the live site or in the draft (`in_use: true` in the list) → `409 RELEASE_ACTIVE`; a draft API key → `403 PAT_CONTOUR_MISMATCH` (history is rollback material of the live site). Files no other version uses are freed from the shop's storage quota (`catalog:write` + `storage:write`). Rolling a site publication back to a deleted version keeps the current one and warns |

The release history is **shared between the draft and the live site**; only the
pointer to the active release is per contour. A release published with
`X-Vizen-Contour: dev` is therefore listed in a live session too, marked
`was_live: false`, and activating it there answers `409 RELEASE_DRAFT_ONLY` —
publish the draft (`POST /v1/dev/publish`) and it goes live through that door.
In a draft session nothing changes. **Versions are never removed automatically**
— the whole history stays for rollback (including rolling a site publication
back); old versions count towards the shop's storage until you delete them
(`DELETE …/releases/{rid}`).

```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`, `/coupons`, `/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, markup and the `hidden`
  attribute stay (`data-*` is stripped — style by class), so the cart still
  looks like your site — but never plan a script for it. Keys from §4.0
  (counters, `vz-auth`, `vz-search`) keep working there. Entering and leaving a
  guarded zone is a full reload, so the layout script runs again;
- 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`.

The shadow injection uses the same parsed layout head as the page, not a
separate author-maintained stylesheet. Only CSS travels: scripts, icons and
loader hints do not. A shop without layout styles gets no additional shadow
wrapper. See the surface table above.

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
is `/docs/webcoding` §2b.

## 8. Silently ignored

| What | What actually happens |
|---|---|
| an unknown `vz-` attribute | removed by the sanitiser, no error |
| level-1 attribute/CSS hooks disappear | follow the sanitising rules in §4; use classes and allowed accessibility attributes |
| `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 |
| an `href` whose scheme is not on the list below | the whole `href` is removed — the text stays and the link is dead |

**The `href` schemes a level-1 document may use**, and nothing else:
`https://` (and `//host`, the same external link), `mailto:`, `tel:`, a same-site path (`/catalog`), a fragment (`#anchor`),
`form:<id>` (opens a lead form) and
`modal:product:<id>` / `modal:group:<id>` with their documented query
parameters (`/docs/webcoding` §14.3). `http://` survives the sanitiser but a release that
contains it is refused at publish (`ref.http`) — use `https://`. `javascript:`
and `data:` are removed, as is any scheme you
invent. At level 3 the markup is served as written and this list does not
apply.

## 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-if` on the loop node runs before its alias is bound (§4) | `packages/sections/src/widgets/html/render.ts`, condition before clone scope |
| `vz-gallery` ignores `preview`; an empty gallery draws nothing (§4) | after-wave pass — no gate |
| `products` is `[]` until the document asks, and `[]` on a page that is no section unless the block names one (§2) | gate `storefront/test/scope-products.test.mjs` |
| `productsCategoryId` picks the feed's section per block; a foreign section gives an empty feed (§2) | gate `storefront/test/products-feed-category.test.mjs` + `component-params.test.mjs` (the key); the tenant half is the server's `company_id AND category_id` — measured on a copy of production |
| the plain feed carries no gallery, description or variants inside one product; chosen attributes and group tiles come only when the block sets `productsAttributes` / `productsMode` (§2) | gate `scope-products.test.mjs` + `vz-reference.json` field list; profiled feed — `storefront/test/list-feed.test.mjs`, `feed-profile.test.mjs`, the published groups example — `scope-products-paging.test.mjs` |
| 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.
