Vizen Shop

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

/docs/own-markupcurrentEN· проверено 2026-08-20

Резюме по-русски. Область своей вёрстки: ключи и подстановки, что делает обёртка блока с вашей разметкой, картинки, скрипты. Развилка с платформенным листингом описана первой, до рецептов, потому что именно она дороже всего стоит, когда не названа. С 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 pathCost
a product row on a section page, any designown markup over the products[] scopelive 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 sectionown markup with the values written inprices and stock freeze — see below
a product row with the platform lookplatform productListing (widgets.md)the look is bounded by props; the strip cannot be recoloured today
a header that is unmistakably yoursown 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 platformkeep 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 timea 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 pageown markup on an empty page with all three layers hiddennothing 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:

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

LevelWhat it meansIndexed
1static, sanitised; keys expand server-sideyes
2scripts inside a sandboxed iframeno
3authored inline in the real page DOM, scripts runyes

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 deepm in menuc in m.childreng 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:

NodeWhat it will do to your layout
.vz-boxgives the block platform air and a square band, and lets everything overflow it. Sticky, background, hairlines and the block radius all live here
.vz-innercaps 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-radiuscuts 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 pathWhat it costs
a full re-layout — your own grid, your own breakpoints, nothing of the platform leftA. `props.wrapperClass` — your class on the band, then your own CSSsomewhere 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 restB. the wrapper axesprops.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 #anchoruniqueness 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:

{ "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:

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

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

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:

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

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

Surfaceprops.position: "sticky"vz-sticky in the document
any block in a page zone (top, presentations, bottom)worksworks
any block in a layout zone / chrome kit — the custom-header caseworksworks
a block nested inside a columns widgetworksnot asked there — use props.position on that child
the chrome widgets siteHeader, siteMenu, siteFooterno — the wrapper never runs for those kindsno
the editor canvas in the admin panelno — 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/widgetssiteHeader).

⚠️ 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 pathsproduct.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 folderWhere it goes
<head> of the skeletonmeta/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 *.cssbare 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 elseserved 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:

MethodPathWhat it does
GET/themes/{id}/sitefolder 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/releasesdraft release from a manifest {base, files[{path,sha256,size}], delete[], note}{release_id, upload[], reused[], inherited[]}
POST/themes/{id}/site/releases/ziprelease from a ZIP (?publish=1, ?base=)
POST/themes/{id}/site/releases/{rid}/publishvalidator → 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}/validatethe 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)
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 rootWhy
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/preconnectthe 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

WhatWhat actually happens
an unknown vz- attributeremoved by the sanitiser, no error
vz-sticky or props.position on siteHeader / siteMenu / siteFooterthose 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 widgetthe 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 scopeprints 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 claimKept 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.

Исходник: https://api.vizen.shop/docs/own-markup

Работает в демо-режиме