{
  "version": 1,
  "what": "Your own markup on shop data. You write plain HTML and CSS; where you need shop data or live behaviour you add a vz- attribute. {{ … }} substitutes values.",
  "keys": [
    {
      "name": "vz-menu",
      "value": "optional",
      "since": "1",
      "does": "Marks the element as the shop menu: the platform wires opening and the active item.",
      "example": "<nav vz-menu>…</nav>",
      "runs": "browser"
    },
    {
      "name": "vz-search",
      "value": "none",
      "since": "1",
      "does": "Turns the input into catalogue search.",
      "example": "<input vz-search placeholder=\"Search\">",
      "runs": "browser"
    },
    {
      "name": "vz-cart-open",
      "value": "none",
      "since": "1",
      "does": "Opens the cart on click.",
      "example": "<button vz-cart-open>Cart</button>",
      "runs": "browser"
    },
    {
      "name": "vz-cart-count",
      "value": "optional",
      "since": "1",
      "does": "Renders the live number of items in the cart.",
      "example": "<span vz-cart-count>0</span>",
      "runs": "browser"
    },
    {
      "name": "vz-add-to-cart",
      "value": "optional",
      "since": "1",
      "does": "Adds the current product to the cart, honouring the chosen variant.",
      "example": "<button vz-add-to-cart>Add to cart</button>",
      "runs": "browser"
    },
    {
      "name": "vz-fav",
      "value": "optional",
      "since": "1",
      "does": "Toggles the product in the wishlist.",
      "example": "<button vz-fav></button>",
      "runs": "browser"
    },
    {
      "name": "vz-variants",
      "value": "none",
      "since": "1",
      "does": "Marks the block of variant choices (sizes, colours); the platform binds selection to price and cart.",
      "example": "<div vz-variants>…</div>",
      "runs": "browser"
    },
    {
      "name": "vz-gallery",
      "value": "none",
      "since": "1",
      "does": "Marks the gallery root; pairs with vz-gallery-main and vz-gallery-thumbs.",
      "example": "<div vz-gallery>…</div>",
      "runs": "browser"
    },
    {
      "name": "vz-gallery-main",
      "value": "none",
      "since": "1",
      "does": "The large frame of the gallery.",
      "example": "<img vz-gallery-main vz-src=\"product.preview_url\" alt=\"\">",
      "runs": "browser"
    },
    {
      "name": "vz-gallery-thumbs",
      "value": "none",
      "since": "1",
      "does": "The thumbnails strip: clicking (or hovering on desktop) switches the main frame.",
      "example": "<div vz-gallery-thumbs><img vz-for=\"i in product.gallery\" vz-src=\"i.url\" alt=\"\"></div>",
      "runs": "browser"
    },
    {
      "name": "vz-lead",
      "value": "required",
      "since": "1",
      "does": "Opens the lead form with the given id.",
      "example": "<button vz-lead=\"12\">Request a call</button>",
      "runs": "browser"
    },
    {
      "name": "vz-quickview",
      "value": "required",
      "since": "1"
    },
    {
      "name": "vz-modal",
      "value": "required",
      "since": "1"
    },
    {
      "name": "vz-toggle",
      "value": "optional",
      "since": "1",
      "does": "Toggles the state named in the attribute — for tabs, accordions, drawers.",
      "example": "<button vz-toggle=\"specs\">Specs</button>",
      "runs": "browser"
    },
    {
      "name": "vz-state",
      "value": "required",
      "since": "1",
      "does": "Shows the element only while the named state is on.",
      "example": "<div vz-state=\"specs\">…</div>",
      "runs": "browser"
    },
    {
      "name": "vz-for",
      "value": "required",
      "since": "1",
      "does": "Repeats the element for every item of a list from the page scope.",
      "example": "<li vz-for=\"m in menu\">{{ m.name }}</li>",
      "runs": "server"
    },
    {
      "name": "vz-if",
      "value": "required",
      "since": "1",
      "does": "Drops the element when the value is empty or false.",
      "example": "<div vz-if=\"product.old_price\">{{ product.old_price_text }}</div>",
      "runs": "server"
    },
    {
      "name": "vz-src",
      "value": "required",
      "since": "1",
      "does": "Sets src from the scope.",
      "example": "<img vz-src=\"product.preview_url\" alt=\"\">",
      "runs": "server"
    },
    {
      "name": "vz-href",
      "value": "required",
      "since": "1",
      "does": "Sets href from the scope. Addresses come canonical (tree paths), so links never bounce through a redirect.",
      "example": "<a vz-href=\"m.url\">{{ m.name }}</a>",
      "runs": "server"
    },
    {
      "name": "vz-edit",
      "value": "required",
      "since": "1",
      "does": "Marks the text as editable in the visual editor. No effect on the live site.",
      "example": "<h1 vz-edit=\"title\">{{ product.name }}</h1>",
      "runs": "browser"
    },
    {
      "name": "vz-app",
      "value": "required",
      "since": "1",
      "does": "Mounts your script in a sandbox frame (level 2). For a full page with scripts inline use document level 3 instead.",
      "example": "<div vz-app=\"quiz\"></div>",
      "runs": "browser"
    },
    {
      "name": "vz-sticky",
      "value": "none",
      "since": "1",
      "does": "Keeps the element pinned while the page scrolls.",
      "example": "<header vz-sticky>…</header>",
      "runs": "browser"
    }
  ],
  "scope": {
    "note": "Available inside {{ … }} and to your script as window.vz (script id=\"vz-page-data\"). One object serves both, so a value never differs between markup and script.",
    "shop": [
      "name",
      "initial",
      "currency",
      "logo_url"
    ],
    "product": [
      "id",
      "name",
      "sku",
      "description",
      "price",
      "price_text",
      "old_price",
      "old_price_text",
      "url",
      "preview_url",
      "gallery[].url",
      "gallery_items[].kind",
      "gallery_items[].url",
      "gallery_items[].poster_url",
      "gallery_items[].html",
      "gallery_items[].caption",
      "galleries[].id",
      "galleries[].name",
      "galleries[].images[].url",
      "shown_gallery.id",
      "shown_gallery.name",
      "tags[]",
      "rating_avg",
      "rating_count",
      "attributes[].name",
      "attributes[].value",
      "promotions[].id",
      "promotions[].name",
      "promotions[].description",
      "promotions[].kind",
      "promotions[].icon",
      "promotions[].theme",
      "combo_items[]",
      "in_combos[]",
      "variants"
    ],
    "gallery": {
      "note": "preview_url and gallery[] are the IMAGES of the CURRENT set (?v=), the same ones the platform card shows: a set bound to a gallery gives that gallery’s images only; a set without a binding gives the preview plus the first gallery. gallery[0] is preview_url, so a thumbs strip over gallery already includes the main frame.",
      "items": "A gallery item is not always an image. gallery_items[] is the same slider with roles: kind is image | video | audio | html (the server derives it from the file type; only html is authored). url is the media file, poster_url the video poster or audio cover, html the embed code, caption the optional line under the slide. gallery[] keeps only kind=\"image\", because that is what an <img> can show — branch on kind yourself when you want to render video or an embed. New roles may appear, so treat kind as open and ignore what you do not handle.",
      "galleries": "galleries[] is the full list of the product’s named galleries (by colour and so on); the first one is the default gallery. A gallery is a shop-level resource, so the same one can sit on several products. shown_gallery is the gallery currently shown — the set’s own, or the first one when the set has no binding; null when the product has none, so guard a caption with vz-if. It is NOT called \"gallery\": that name is already the current set’s frames. images[] of a gallery that is not the shown one carries ONE cover frame, not the whole set: the page asks the card endpoint for the shown gallery only, because a product photographed in 360° for a dozen colours would otherwise ship hundreds of frames nobody looks at. A colour switcher needs the cover; the frames themselves arrive when ?v= switches the page, or from GET /galleries/{id}.",
      "example": "<p vz-if=\"product.shown_gallery\">Photos: {{ product.shown_gallery.name }}</p><div vz-gallery-thumbs><img vz-for=\"i in product.gallery\" vz-src=\"i.url\" alt=\"\"></div>"
    },
    "combos": {
      "note": "Two lists, and they answer different questions. combo_items[] is what a SET contains — use it on the set’s own card. in_combos[] is the sets a COMPONENT belongs to — use it on an ordinary product card to say \"cheaper as part of a set\". Both are already on product; neither costs an extra request.",
      "in_combos": [
        "name",
        "url",
        "preview_url",
        "benefit",
        "benefit_text",
        "combo_price",
        "combo_price_text",
        "components_total",
        "components_total_text",
        "item_price",
        "item_price_text",
        "item_regular_price",
        "item_regular_price_text",
        "item_times_cheaper_x100",
        "item_times_cheaper_text"
      ],
      "combo_items": [
        "name",
        "url",
        "preview_url",
        "quantity",
        "price_text",
        "base_price",
        "base_price_text",
        "effective_price",
        "effective_price_text"
      ],
      "pairs": "Every number has a ready string beside it, because substitutions have no expressions: {{ c.item_price }} sorts, {{ c.item_price_text }} prints. item_times_cheaper_text is a NUMERAL (\"2,5\"), not a sentence — you write the words: \"дешевле в {{ c.item_times_cheaper_text }} раза\". The separator follows the shop currency, so it matches the money already on the page.",
      "honesty": "item_times_cheaper_text is EMPTY whenever a ratio would overstate: no benefit, same price, dearer in the set, or a cut below 1.1× (where truncation would print \"1\" and your sentence would read \"cheaper by 1 times\"). Rounding is always DOWN — 1.97× prints 1,9 — so the page never promises more than the cart delivers. Guard the line with vz-if and it disappears on its own.",
      "gift": "A free component is item_price 0 with item_regular_price above 0, and its ratio is empty on purpose — there is no ratio against zero. Render a gift from item_price == 0, not from the ratio. When item_regular_price is 0 there is simply nothing to say — the component costs nothing on its own — so the texts are empty rather than \"0 ₽\", which a shopper reads as \"free\".",
      "why_a_set_can_be_silent": "The in-set price is computed from the BASE price, never from the promotional one, so that a set does not change price every time a component goes on sale. A deep enough discount on a component can therefore make it cheaper on its own than inside the set — and then the ratio is 0 and the line disappears. That is correct, not a bug. Never compute your own ratio from the two prices: this is exactly where it would disagree with the cart.",
      "example": "<p vz-if=\"c.item_times_cheaper_text\" vz-for=\"c in product.in_combos\">В наборе «{{ c.name }}» — {{ c.item_price_text }} вместо {{ c.item_regular_price_text }}, дешевле в {{ c.item_times_cheaper_text }} раза</p>"
    },
    "category": [
      "id",
      "name",
      "description",
      "url"
    ],
    "products": {
      "note": "Products of the CURRENT section, live. This is what makes an own-markup row honest: before it existed, prices had to be typed into the document and went stale the moment the catalogue changed. Loaded only when the document asks for it — write vz-for=\"p in products\" — so a page that does not use it pays nothing. Up to 48 items; preview is already a resizer path, so an ordinary <img src=\"{{ p.preview }}\"> is a thumbnail rather than a multi-megabyte original.",
      "fields": [
        "id",
        "name",
        "sku",
        "price",
        "price_text",
        "old_price",
        "old_price_text",
        "url",
        "preview",
        "in_stock",
        "promotion_name"
      ],
      "promotions": "price and old_price already account for promotions — the server runs every card through the same engine the cart uses. promotion_name is the rule that fired, empty when none did. Deliberately absent: the discount amount (derive it from the two prices, or your number will drift from ours on rounding), the validity period (it belongs to the rule, and several rules can cover one product), and any \"price with a promo code\" flag — coupon-gated rules never reach a card, because a storefront must not promise a discount that does not exist without the code, and a personal price in a shared page scope would leak someone else’s coupon.",
      "example": "<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>"
    },
    "menu": [
      "name",
      "url",
      "img",
      "children[]"
    ],
    "crumbs": [
      "name",
      "url"
    ],
    "company_id": "number",
    "empty": "product is null on any page that is not a product page; category likewise. Guard with vz-if so the node disappears instead of rendering blanks."
  },
  "markup_contract": {
    "note": "Classes, variables and attributes the platform guarantees and will not rename. Anything not listed here is internal and may change without notice — the previous run relied on several of these after reading them off the live page, which is a fair complaint: if we hand out hooks, we owe you a list of the stable ones.",
    "where_legend": {
      "note": "Every entry says WHERE it exists, because an html block renders inside a shadow root and the two sides do not see each other’s CSS. Targeting the wrong side is silent: the rule is valid, it simply matches nothing.",
      "page": "Lives in the page document, outside any shadow root. Reachable from global CSS and from ordinary document.querySelector.",
      "shadow": "Lives INSIDE the shadow root of an html block. Global CSS cannot reach it — put the rule in the block’s own <style>, and query it through the host element’s shadowRoot, not from the document.",
      "both": "Exists on both sides with the same name. Style it once in each place you need it; a single global rule still will not cross into the shadow root."
    },
    "entries": [
      {
        "name": ".vz-box",
        "kind": "class",
        "what": "The band of one block: padding, background, radius and the sticky/overflow axes live here. It is also the container-query context, so @container rules inside a block measure the block rather than the viewport.",
        "where": "page"
      },
      {
        "name": ".vz-inner",
        "kind": "class",
        "what": "The content column inside a band — width and centring. Put it on your own node to align with the site grid; it is available inside your shadow root too.",
        "where": "both"
      },
      {
        "name": ".vz-pad",
        "kind": "class",
        "what": "Side padding by the page step (16 / 24 / 32). Use it on a full-bleed block so text does not touch the screen edge.",
        "where": "shadow"
      },
      {
        "name": ".vz-radius",
        "kind": "class",
        "what": "Applies the block radius to an inner element — images, panels, media.",
        "where": "page"
      },
      {
        "name": "--vz-gutter",
        "kind": "variable",
        "what": "Current side gutter of the page (16 / 24 / 32 by breakpoint).",
        "where": "shadow"
      },
      {
        "name": "--vz-content-box",
        "kind": "variable",
        "what": "Width of the site content column, minus gutters. Custom properties cross the shadow boundary, so your markup can use it directly.",
        "where": "both"
      },
      {
        "name": "data-vz-kind",
        "kind": "attribute",
        "what": "What widget a node on the page is — read it to find what you are looking at.",
        "where": "page"
      },
      {
        "name": "data-vz-block",
        "kind": "attribute",
        "what": "The content block a section really lives in — the id you would PUT to change it.",
        "where": "page"
      },
      {
        "name": "data-vz-kit",
        "kind": "attribute",
        "what": "Chrome parts carry the kit id instead of a block id: the kit arrives materialised, and naming a block that is not the one you would edit would be worse than naming none.",
        "where": "page"
      },
      {
        "name": "data-vz-idx",
        "kind": "attribute",
        "what": "Position of the section inside its block — sections are a replace-set, so the index matters.",
        "where": "page"
      }
    ]
  },
  "levels": [
    {
      "level": 1,
      "name": "HTML/CSS with keys",
      "gives": "your markup plus page data and behaviour; expanded on the server",
      "costs": "no scripts of your own — they are stripped",
      "indexed": true
    },
    {
      "level": 2,
      "name": "Script in a sandbox",
      "gives": "any logic: quiz, calculator, configurator",
      "costs": "lives in its own frame — not indexed, no access to the page",
      "indexed": false
    },
    {
      "level": 3,
      "name": "Authored inline (HTML project)",
      "gives": "your HTML, CSS and <script> rendered as real page DOM; the regular path for a finished page — the widget is the folder, files are served from /_html/{doc}/{release}/…, scripts read the root from window.VZ_ASSET_BASE",
      "costs": "role owner/admin in the shop plus the server flag; served only where the shop owns the origin; never on cabinet or checkout",
      "indexed": true
    }
  ],
  "rules": [
    "Next to every number there is a ready string: price and price_text. Substitutions have no expressions or functions.",
    "Addresses arrive canonical (tree paths) — do not rebuild them by hand.",
    "Anything prefixed vz- that is not in this list is removed by the sanitiser.",
    "The markup itself is stored as a FILE: upload it (3 steps), then create the document and point the block at it.",
    "A 200 does not prove the result. Look at the page.",
    "Level 3 scripts run on a direct hit AND after an in-site link click — but never hide content behind them: show the content, let the script only decorate it.",
    "Check both ways: open the URL directly, then reach the same page by clicking a link inside the site."
  ],
  "component": {
    "what": "The same folder of HTML/CSS, but reusable: next to index.html lies component.json with a schema of typed parameters, and the markup carries {{key}} placeholders. The owner puts it on five pages with five different sets of values; you ship a new release of the folder and all five update. Full area doc: /docs/components.",
    "when": "Use a component when the SAME markup must appear more than once with different content. Use a plain html document (level 3) when the markup belongs to one page.",
    "document": "POST /html-documents with kind:\"component\" (values: \"\" | widget | component), then the ordinary folder flow: POST /html-documents/{id}/releases/zip?publish=1. A component release MUST contain component.json or publishing is refused with COMPONENT_MANIFEST_INVALID naming the offending field.",
    "schema": "component.json = {name, params:[{key,type,label,default,max,options}], presets:[…]}. key matches ^[a-z][a-z0-9_]{0,31}$, at most 32 params, file at most 16 KB. It is a Shopify theme-section schema with our type names: text→text, richtext→html, image_picker→image, url→link, color→color, number→number, checkbox→bool, select→select. product/collection are NOT available yet.",
    "substitution": "{{key}} is replaced SERVER-SIDE and BY TYPE: text is HTML-escaped, html goes through the level-1 sanitiser, image resolves a file_id to the resized URL, link accepts only http(s), a site path, mailto: and tel:, color only #hex, select only a member of options — anything else becomes an empty string. {{#key}}…{{/key}} shows a block when a bool is true. A key absent from the schema renders as empty: there is no raw {{ }} for a component.",
    "keys": "Platform keys are NOT touched by that pass: {{ product.name }} and the vz- attributes still work inside a component and are expanded by the storefront with the page scope. Only a bare {{key}} belongs to the component schema.",
    "section": "The insertion is an ordinary page section: {type:\"text\", payload:{v:2, kind:\"component\", props:{component:{ref:<document id>, params:{…}}}}}. params is a flat {key: value} object, at most 8 KB; unknown key ignored, missing key falls back to the schema default.",
    "read": "GET /html-documents/{id}/content?company_id=…&params=<url-encoded JSON> returns the same {html, level, updated_at} as an html widget, with values already substituted."
  },
  "howto": {
    "create": "POST /v1/storages/files (3 steps: request → bytes → confirm by upload.id) → POST /html-documents with source_file_id and level → put the document id into a block section of kind \"html\" → PUT /content-blocks/{id}.",
    "read": "GET /html-documents, GET /html-documents/{id}/content?company_id=…",
    "scopes": "catalog:write to create and edit; storage:write to upload the markup.",
    "one_off_landing": "Your markup should own the whole page: create an empty page and hide all three layers at once — PUT /content-blocks/{id of __page:top} with page = {hideSiteHeader:true, hideSiteFooter:true, hideSystemBlock:true}. Nothing is deleted; clearing the flags brings the layers back. See kind \"zone\" in /docs/widgets.",
    "site_wide_header": "A custom header must NOT be built inside a landing page — it would show on that one page while the old header stays everywhere else, leaving the shop with two. Build it in the LAYOUT, zone top (footer — zone bottom): layout parts are shared, so one edit reaches every page of the shop."
  }
}
