---
name: vizen-own-markup
description: Write your own HTML, CSS and JS into a Vizen shop — vz- keys and substitutions, isolation levels, what the block wrapper does to your layout, images through the resizer, live catalogue data in your own markup, modals and dropdowns, and the component with parameters when the same markup has to stand on many pages. Use when the task asks for a unique design rather than platform widgets.
---

# Your own markup

The platform substitutes data **on the server** and hands back plain HTML. No
foreign JavaScript is required for data, and the text is in the page source, so
search engines see it.

## 1. Two vocabularies

**Substitutions** print data: `{{ shop.name }}`, `{{ p.price_text }}`.
**Keys** are attributes that add behaviour or structure: `vz-for`, `vz-if`,
`vz-add-to-cart`, `vz-toggle`.

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

## 2. What is in scope

`shop`, `menu`, `crumbs` everywhere; `product` on a product page; `category` and
`products` on a section page.

**`products` is the section's feed, live.** Write `vz-for="p in products"` and
you get real catalogue rows — the price you print is the price the cart charges,
and it keeps up when the owner edits the catalogue. This is recent: until
2026-08-20 there was no product list in scope at all, and a custom row meant
prices typed into the document by hand, which started lying the moment anything
changed. If you have older notes saying own markup freezes prices, they are out
of date.

The feed is loaded **only when your document asks for it**, so a page that never
mentions `products` pays nothing for it. Fields: `id`, `name`, `sku`, `price`,
`price_text`, `old_price`, `old_price_text`, `url`, `preview`, `in_stock`,
`promotion_name`. Up to 48 rows.

`preview` is already a resizer path, so a plain `<img src="{{ p.preview }}">` is
a thumbnail rather than a multi-megabyte original. Do not rebuild it by hand.

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

### Discounts and sets

Prices in the feed already account for promotions — the server runs every card
through the engine the cart uses. `promotion_name` is the rule that fired, empty
when none did.

On a **product page**, `product.in_combos[]` lets the card say where the product
is cheaper, with numbers and ready strings side by side:
`item_price_text` is what it costs inside that set, `item_regular_price_text`
what it costs alone, and `item_times_cheaper_text` is a NUMERAL — you write the
words around it.

```html
<p vz-for="c in product.in_combos" vz-if="c.item_times_cheaper_text">
  In «{{ c.name }}» — {{ c.item_price_text }} instead of
  {{ c.item_regular_price_text }}
</p>
```

`item_times_cheaper_text` is empty whenever a ratio would overstate the benefit,
so guarding with `vz-if` is all the honesty check you need. A gift is
`item_price` equal to 0 — render it from that, never from the ratio.

Full field list and the rules behind the numbers: `GET /docs/vz-keys.json`
(`scope.combos`) and `GET /docs/promotions` for the discount engine itself.

Live regardless of everything above: `vz-add-to-cart`, `vz-cart-count`, `vz-fav`,
`vz-variants` — buttons keep working.

**Still a real fork.** The platform listing (`vizen-widgets`) brings filters,
paging and sorting; the feed in scope does not. Choose own markup for the look,
the listing for the machinery — but no longer for the freshness of the data.

## 3. Verified behaviours you will not find in the guide

- **nested `vz-for` works three levels deep** — `m in menu` → `c in m.children` →
  `g in c.children`, expanded on the server, SEO intact. That is how a mega menu
  is built;
- **`vz-for` and `vz-if` on the same node** make the node disappear entirely —
  put them on different nodes;
- **`vz-toggle` with no value** sets `vz-state="open"` on the node itself. That
  is the only way to build a click-dropdown without JS:
  `.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.

## 4. "I want this block to look unique" — the route

Every block **with a box** — a platform widget, a level-1 html block, a level-3
block with `props.html.boxed: true` — is drawn inside the same wrapper:
`div.vz-box` (the band — background, padding, radius, and the node that goes
sticky) → `div.vz-inner` (the content box — it caps your width) → for
`kind: "html"` at level 1, `div.vz-radius`, the shadow host, **which clips your
dropdowns and modals by default**. A transferred folder (level 3, unboxed) has
none of these: your root element is the block, and the axes below have nothing
to land on — style your root directly, or set `boxed: true` first.

Do not fight it, and do not restyle its class names. Pick one of two moves:

| You want | Move |
|---|---|
| a **full re-layout** — your grid, your breakpoints, nothing of the platform left | **`props.wrapperClass: "my-hero"`** → your class lands on the band → you restyle it from your own CSS: `.my-hero > .vz-inner { max-width: none; display: grid }` |
| **one behaviour** switched off — clipping, the width cap, the air, the stacking | the wrapper **axis** for it: `props.overflow: "visible"`, `props.blockWidth: "full"`, `props.paddingSides`, `props.position: "sticky"` … |

Both are written at the root of `props`, next to `kind` — not inside the
widget's own write path. `props.wrapperId` gives the band one id, for `#anchors`
and for your scripts.

Prefer the axis when an axis can do it: it is read by the code that draws the
block, so it survives changes to the markup.

⚠️ **Two things that make Path A silently do nothing:**

- **level-1 CSS cannot see the wrapper.** Your `<style>` lives inside the shadow
  root; `.vz-box` is outside it. Path A needs a **level 3** document, which is
  real page DOM. Level 3 needs the owner/admin role; the server flag is on in
  production — if `POST /html-documents {level:3}` is refused, report it to the
  owner instead of falling back to level 1;
- **names are dropped whole, not cleaned.** At most 3 classes, one id, pattern
  `^[a-zA-Z_][a-zA-Z0-9_-]*$`, and nothing may start with `vz-` (the platform's
  own namespace). A rejected name simply is not in `class=`.

⚠️ **Never target `.vz-box`, `.vz-inner`, `.vz-radius`, `.vz-edge`, `.vz-vis-*`
directly.** They are internal, they carry no version, and they have already
moved once — that is why `wrapperClass` exists. Always scope through your own
class: `.my-hero > .vz-inner`, never `.vz-inner` alone.

**Sticky works** — `props.position: "sticky"` on any block, or `vz-sticky` on the
root node of your markup, including a custom header inside a chrome kit. It is
applied to the band, the only node with room to travel. Older notes prescribing
`position: fixed` plus a spacer are obsolete. `props.overflow: "clip"` and sticky
are mutually exclusive.

**Where the numbers live.** Every default of both wrapper divs, with the
`file:line` in the storefront that sets it, is generated into
`GET /docs/widgets` → `wrapper`:

```bash
curl -s https://api.vizen.shop/docs/widgets | jq -r '
  .wrapper.elements[] as $e | $e.defaults[]
  | [$e.selector, .property, .value, (.when // "-"), .anchor.at] | @tsv'
```

Full recipe, the anti-path and the surfaces where sticky is still not asked:
`GET /docs/own-markup` §5.

The block's own padding and radius are **zero** for `kind: "html"` — the platform
adds nothing around your design. Want air? Set `props.paddingSides` explicitly.

## 5. Images

Write an ordinary `<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.

**But set `sizes` yourself.** Only you know how wide the slot is; without a hint
the server assumes full width and the browser takes the largest step. On a card
268px wide that meant 1600w instead of 640w. Your `sizes` is never overwritten,
and your own `srcset` disables the rewrite entirely.

Scope image fields are **already resizer paths** since 2026-08-20 —
`product.preview_url`, `product.gallery[].url`, the combo previews and
`products[].preview`. Print them as they come; rebuilding one by hand is how you
ship a multi-megabyte original.

⚠️ `vz-src` is filled in *after* the server rewrite has run, so nothing rewrites
it. That is safe 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.

## 6. Scripts

Level 3 renders inline, in the real page DOM. Scripts run on a direct hit **and**
after an in-site link click. Two rules:

- **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. Write code that works when executed right now.

Check both ways: direct URL and a click from another page.

## 6.1 A finished folder → a page of the site (HTML project)

The transfer path lives in the skill `vizen-transfer` and the area
`GET /docs/transfer`: folder = level-3 widget in real page DOM, one command per
folder (`node backend-3D/tools/html-transfer/transfer.mjs ./folder --page <slug>`),
nothing cut, no path rewritten, and the contract of what the platform does to
your file — the wrapper `.vz-body-{doc}` with `data-vz-base`, the head, CSS
scoping, the shared cascade with the storefront's preflight, scripts re-run
after soft navigation, the guarded zones. Two facts stay here because they are
about keys inside the folder:

- the scope (`shop`, `menu`, `crumbs`, `product`, `category`, `products[]`) and
  every `vz-` key of §1–3 work inside a transferred folder exactly as in any
  other level-3 document — that is how a moved product page shows the real
  product;
- ZIP (`POST /html-documents/{id}/releases/zip?publish=1`) creates a release
  only: the document must exist and the block is mounted by hand; only
  `transfer.mjs` creates the document and mounts the block.

- **One mechanism — the release.** Script, ZIP, manual manifest and the
  admin's "Page code" window all end in a release. "Change this text" on a
  published page = a partial release, no re-upload of the project: find the
  document id (`GET /html-documents` by name, or
  `GET /categories/by-slug/{slug}?company_id=…` →
  `content_blocks[].sections[].payload.props.html.html_document_id`) → read
  the current clean markup from `GET /html-documents/{id}` →
  `result.source.url` (the active release's `index.html`, already without
  head/body) → edit → `POST /html-documents/{id}/releases
  {"base":"active","files":[{"path":"index.html","sha256":"…","size":N}],"note":"…"}`
  → `PUT` the bytes to `upload_url` → `POST …/releases/{rid}/publish`. Every
  other file is inherited from the active release; rollback stays
  (`POST …/rollback`). One image: the same call with that path; remove:
  `"delete":["path"]`. `index.html` is never deduplicated (the converter
  changes it in the release) — normal, not an error.
- Full recipe and the folder rules: `/docs/webcoding` §2а.

## 6.2 Site-wide CSS, fonts and scripts — the LAYOUT's folder

⚠️ **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 on a live shop) — 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**, which
lands on every page.

A layout is a group-container (`group_type:"theme"`) that also holds the slots
header/feed/body/footer for the pages it covers (`vizen-chrome`). Its folder is
the release mechanism of §6.1 addressed by the layout, not by a document id:

```bash
# which layouts the shop has — filter the list yourself, the API has no filter
curl -s -H "Authorization: Bearer $VZ" $API/content-blocks \
  | jq '[.result[] | select(.group_type=="theme") | {id,name}] | sort_by(.id)'   # earliest = "Основной"

node backend-3D/tools/html-transfer/transfer.mjs ./theme --theme 118   # folder → release → publish
curl -s -H "Authorization: Bearer $VZ" $API/themes/118/site | jq '{base, scope_class, icons_generated}'
```

`index.html` here is the **skeleton of the site**, not content: `<head>` nodes go
into the head of every page, the `<body>` class onto the page root, the body
content before `</body>`; `<title>`, `<base>`, `<meta http-equiv>` and
`<link rel=manifest>` are stripped on publish. Any `*.css` in the folder has its
bare `html`, `body` and `:root` selectors scoped to `.vz-theme-{doc}` — the class
of the page **root** — so `body {}` and `:root {--brand: …}` are the intended way
to style the site (compound `body.dark` is deliberately left on the real body).
`icon.png` (or `.jpg`/`.jpeg`/`.webp`) generates `icon-32/180/192/512.png` and
`favicon.ico`; your own files under those names are never overwritten.

Endpoints: `GET /themes/{id}/site` · `POST …/site/releases` · `…/releases/zip` ·
`…/releases/{rid}/publish|validate` · `GET …/site/releases[/{rid}]` ·
`POST …/site/rollback`. Same scopes and role as §6.1. The folder's service
document is invisible in `/html-documents` and refuses edits
(`THEME_DOCUMENT_PROTECTED`); a bad id is `404 THEME_NOT_FOUND`.

Two things to plan around:

- **scripts do not run in the guarded zones** — `/cart`, `/checkout`,
  `/account`, `/wishlist`, `/orders`, `/deals`, `/dashboard` and anything under
  them. No `head_html`, no `foot_html`, no `window.VZ_THEME_BASE`, and a level-3
  header renders without its scripts. Styles and markup stay;
- navigation is soft: after every navigation **and the first load**
  `document` gets `CustomEvent('vz:navigate', {detail:{path}})` — re-init from
  it. Moving to a page with a different layout is a full reload.

Scripts of the layout read their asset root from `window.VZ_THEME_BASE`
(`/_html/{doc}/{rid}/`). Files of the ACTIVE layout also have a second,
unversioned address — `/_site/<path>` — for links that outlive a release (an
email, a signature, someone else's site); inside the site keep using
`/_html/{doc}/{rid}/…`, which is immutable and cached forever.

**The layout's CSS now reaches inside a level-1 shadow root** (2026-09-13). A
shadow root isolates in both directions, so a level-1 widget used to render
"without the theme" on a site that had one. The storefront now puts the layout's
stylesheets into the shadow root as well and wraps your markup there in
`<div class="vz-theme-{doc}">`, so the theme's fonts, CSS variables and
`body {}` / `:root {}` rules apply — while your own rules still win, because the
theme comes first. Scripts of the layout are **not** injected into the shadow
(it is drawn in the guarded zones too), and a level-1 header still keeps its OWN
CSS to itself. Full recipe: `/docs/own-markup` §7.2, `/docs/webcoding` §2б.

## 6.3 A component with parameters — the same markup on many pages

Ask one question before you write the markup: **is this needed more than once?**
If the answer is "on five pages, with a different heading and picture each time",
an html widget is the wrong object — you would be copying code, and the sixth
edit would be six edits. That case is a **component**: the same folder and the
same releases, plus a schema of parameters. The owner fills the values per
insertion and never touches the code; you publish a new release and all five
insertions change at once. Full area: `GET /docs/components`.

**The folder is the html folder plus one file.** `index.html`, CSS, JS as always,
and `component.json` beside them — publishing a component release without it is
refused.

```json
{ "name": "Promo banner",
  "params": [
    { "key": "title", "type": "text",  "label": "Heading", "default": "Sale", "max": 80 },
    { "key": "image", "type": "image", "label": "Picture" },
    { "key": "sale",  "type": "bool",  "label": "Show the badge", "default": false },
    { "key": "size",  "type": "select", "options": ["s", "m", "l"], "default": "m" } ] }
```

If you know Shopify theme sections, you already know this file: `component.json`
is `{% schema %}`, `params` is `settings`, and the type names map one to one
(`text`→`text`, `richtext`→`html`, `image_picker`→`image`, `url`→`link`,
`checkbox`→`bool`, `select`→`select`, `number`/`range`→`number`, `color`→`color`).
`product`/`collection` have no counterpart yet — a component's parameters are
values, not queries.

**In the markup the parameter is `{{key}}`**, and a `bool` also opens a block:

```html
<section class="promo">
  <h2>{{title}}</h2>
  {{#sale}}<span class="badge">-30%</span>{{/sale}}
  <img src="{{image}}" alt="">
</section>
```

Substitution is **typed and server-side**: `text` is escaped, `html` goes through
the level-1 sanitiser, `link`/`image` are checked against a whitelist of schemes,
a `select` outside `options` and a `color` that is not `#hex` become empty. There
is no raw `{{ }}` on the storefront, and a key that is not in the schema prints
nothing — both in the markup and in the values of an insertion.

**TypeScript is allowed in the folder.** Put `app.ts` next to `index.html` and
point the tag at the compiled neighbour (`<script type="module" src="app.js">`) —
publishing compiles every `*.ts` into a sibling `*.js` and keeps the source, so
the next release can be built from `base=active`. Nothing is bundled: every
import must resolve to a file of the folder, written with the extension the
browser will fetch (`"./util.js"`, never `"./util.ts"`). No `.tsx`, no `npm`
imports, no source maps. A refusal is `COMPONENT_TS_ERROR` with `file:line:col`,
or `COMPONENT_TS_CONFLICT` when a hand-written `app.js` already sits next to
`app.ts`. And remember §4: at **level 1** the sanitiser strips `<script>` from
the page anyway — live code needs a level-3 document, exactly as an html project
does.

```bash
# create the document, upload the folder, publish and see the parsed schema
node backend-3D/tools/html-transfer/transfer.mjs ./banner --component --name "Promo banner"
```

**Putting it on a page** is an ordinary section — the values live in the
insertion, never the markup:

```json
{ "type": "text", "payload": { "v": 2, "kind": "component",
  "props": { "component": { "ref": 42, "params": { "title": "Autumn", "sale": true } } } } }
```

`ref` is the document id; `params` is a flat `{key: value}` object. Written at
any other nesting level (`props.params`, `ref` next to `kind`) the section
renders with defaults only and answers 200 — read the page, not the status code.

**Shipping a new version changes every insertion.** Upload a new release and
publish: insertions store values, not markup. A value whose key disappeared from
the new schema is ignored, a parameter an insertion never set falls back to the
new `default`, and `POST /html-documents/{id}/rollback` brings back the previous
release *together with its own schema*. You never read `component.json` through
the gateway to learn what a component takes: the parsed schema comes back on the
document (`manifest`, base64 of the raw JSON, next to `active_release_id`) and on
each release (`manifest` as a plain object). `active_release_id` is the honest
test for "can this be placed yet" — no active release, no markup and no schema.

## 7. Before you finish

`vizen-troubleshoot` holds the acceptance checklist. The minimum: untouched pages
unchanged, both navigation paths work, text present in raw HTML, cart accepts an
item.
