# Site chrome: header, menu, footer and the page skeleton

**Status:** current · **Verified:** 2026-08-21, live calls on the stand + browser;
§3.6 — 2026-09-12, against the code (domain, resolver, handlers); the §3.6 tail (`/_site/`,
article layout, server-side chrome and level-2 menus) — 2026-09-13, against the code of both repos
**Owner:** Vizen API documentation · **Serves:** `GET /docs/chrome`
**Related guide:** `/docs/webcoding` §16.2 (own header, one page)
**Skill:** `vizen-chrome`

> **Shops on the old model.** The `chromeKit` kit and the pair of slots
> `chrome`/`layout` keep working — the same endpoint reads them, and a shop that
> has them configured loses nothing. New code writes groups; the old description
> is kept in §3.5 as a reference for what you may meet in existing data.

## 1. What you can do here

Build the parts that repeat on every page — header, menu, footer — and decide
**which pages get which**. The unit is a **group**: an ordinary content block
holding an ordered list of references to other blocks. A group becomes visible by
being bound to a resource: the whole shop, a category, everything inside a
category, one product. Binding one section leaves the rest of the shop as it was,
and that is what makes a second design possible inside a live shop.

## 2. Decide first (forks)

| If you want… | Take this path | Cost |
|---|---|---|
| a tidy, branded header in minutes | platform widgets `siteHeader`/`siteMenu`/`siteFooter` (`widgets.md`) in a `chrome` group | props configure the standard look (~30 on `siteHeader`); layout CSS supports further restyling (§4); `sticky` works, `siteMenu.level2` gives real dropdowns — they open **on click**, not on hover |
| a header that is unmistakably yours | own markup (`own-markup.md`) as a block inside the same group | your markup and CSS; verify sticky/overflow/containment and dropdown clipping on the chosen surface (§4.2) |
| the new chrome on the whole shop | bind to `shop`, id `0` | every page changes at once |
| the new chrome on one section only | bind to `category` / `category_children` | the rest of the shop keeps the old one — **verify that**, do not assume it |
| a page with **no** header at all | bind slot `header` to `0` on that resource (§3.3) | stops the ladder for that slot only; the footer stays |

**A group takes any block, including `kind:"html"`.** That is how a fully custom
header is built. The fact was originally a probe result on a live shop — no guide
predicted it — and it survives the new model unchanged: the list is references,
and a reference does not care what the block draws.

## 3. Objects and where they live

### 3.1. Widget, group, binding — the whole model

```
WIDGET   — what to draw            (one block = one widget)
GROUP    — a named list of widgets (block + label + ordered references)
BINDING  — which group, where      (shop / category / product, per slot)
```

**The group** is a content block with one section, `kind: "group"`, whose
composition lives in `props.widgets` — an ordered list of block ids — plus a
label in `group_type`. The label belongs to the *set*, not to its contents, so
reordering the widgets inside never changes what the group is:

```jsonc
POST /content-blocks
{ "item": { "name": "Neon header", "group_type": "chrome", "sections": [
    { "type": "text", "payload": { "v": 2, "kind": "group",
      "props": { "widgets": [3146, 3147] } } } ] } }
```

**Builder-authored groups have two coordinated lists.** In addition to
`payload.props.widgets`, they can carry `payload.props.zoneFlow.entries` and
`payload.page.zoneProps`. Preserve page layout/zone properties and unknown
fields. If `zoneFlow` exists, each entry's `ref` must match `widgets` at the
same index; counts must match. Keep unchanged occurrences' `entryId`; give
new occurrences fresh unique `entryId` values, preserving or deliberately
choosing `side` and `zoneId`. Do not write just one list.

For `POST`/`PUT /content-blocks` carrying modern page layout, zone ids or
`page.zoneProps`, put **`"page_design_version": 1` at the request root**, next
to `item`. Otherwise the writer gate returns `400 PAGE_DESIGN_WRITER_UNSUPPORTED`.
Send it on every `/content-blocks` write of the category replacement and its
rollback (`/docs/components` §4.8). `PUT /categories/{id}/content-blocks`, the
binding list, does not take it: there it is `400 INVALID_REQUEST`.

| `group_type` | Fits the slot | Holds |
|---|---|---|
| `chrome` | any slot but the feed | widgets of the frame |
| `product` · `category` · `news` · `article` | any slot but the feed | the template that draws the resource |
| `design` | any slot but the feed | anything — a promo strip, a cover |
| `layout` | `feed` only | **other groups** plus the body marker (§3.4) |
| `theme` | site layout container (§3.6) | empty widget list; slots are design bindings |

`layout` (a feed group) and `theme` (the site layout) are distinct registry types.
For the site layout and its CSS folder, use §3.6–3.7.

The label names a set; it does not gate where the set may stand. The one
exception is the feed, and that is structural, not editorial: its list holds
groups and the body marker, so a widget list put there would be misread.
A cover group as somebody's header is legitimate.

The label set is open: a new kind is one row in the core registry
(`domain.DesignGroupTypes`), no migration and no changes inside sections.

### 3.2. The page skeleton — four slots

```
header · feed · body · footer
```

The order is structural: it is not in the data and you cannot move the slots.
Each slot inherits **on its own ladder**, so overriding the feed of one page does
not drag its body along:

```
this resource → the category chain up the parents → the LAYOUT → the shop → the earliest group of that type
```

The last rung replaces the old "default template" as a special entity. It takes
the **earliest** group of the type, not the latest: creating a new variant must
not silently repaint every page of the shop that never made an explicit choice.

The LAYOUT rung is new (2026-09-12) and optional: a shop without layouts, or
with a layout that binds nothing, resolves exactly as before — see §3.6.

```
PUT /design/bindings/{resource_type}/{id}
{ "slots": { "header": 1712, "footer": 1713, "body": 1716 } }
```

⚠️ The id in a slot is a **GROUP** (§3.1) — a block whose section is
`kind:"group"` — never a widget block. A widget block is refused with
`DESIGN_SLOT_NEEDS_GROUP`. The response may also carry `warnings[]`
(`{slot, code, message}`): the binding **is** stored, but its effect is not
visible yet — today the only one is `DESIGN_BODY_ON_PAGE_NOT_RENDERED`.

| `resource_type` | Applies to |
|---|---|
| `shop` (id must be `0`) | shop-wide default |
| `category` | that category page. ⚠️ A `type:"page"` category accepts a `body` binding and **does not draw it yet** — the storefront support for that type is not built; make the landing a `type:"product"` category instead (`catalogue.md` §5) |
| `category_children` | products inside — **inherited down the whole subtree** |
| `product` | one product |
| `theme` (id = the layout group) | the layout's own slots — what its pages get when nothing closer is bound (§3.6) |
| `system` (id from the registry in §3.6) | a platform page: cart, checkout, account… — `theme`, `header`, `footer` only |

**`slots` is a replace-set.** `{"slots":{"header":X}}` on a resource that also had
a `body` drops the body; `{"slots":{}}` unbinds everything and the resource goes
back to inheriting.

### 3.3. Three answers per slot, not two

| You write | Stored as | The page gets |
|---|---|---|
| slot absent from `slots` | no row | inherited from above |
| `"header": 0` | row with an empty reference | **nothing** — the ladder stops here |
| `"header": 1712` | row with the reference | that group |

`0` is the supported way to say "this page has no header". There is no
`hideSiteHeader`-style flag to pair it with, and adding one back would be a second
mechanism for a thing that already has one.

### 3.4. The feed and the body marker

A `layout` group is the page **feed**: its list holds other groups and one
special entry, `-1`, marking where the resource body lands. Ids are positive, so
the marker cannot be confused with one.

```jsonc
"props": { "widgets": [2100, -1, 2101] }   // cover · body · banner
```

Drop the marker and the body is gone from that page — the author keeps a clean
canvas of their own groups. No feed bound at all means the page is just its body.
A feed inside a feed is refused: one rule instead of cycle detection and a depth
limit.

The feed is a binding, so it follows the ladder: bind it to a category or a layout
to give many pages the same sequence around their body. A feed bound to one page
replaces the feed that page inherits. For blocks on **one** page before or after
its body, use the page's own zones (`__page:top` / `__page:bottom`, `__page:main`
on grid pages — `/docs/components` §4.7.1): they add to the page and inherit nothing away.

### 3.5. What you may still meet: the legacy kit

Shops set up before the group model carry a `kind:"chromeKit"` block with
`props.kit.{top,bottom}` bound through the slot `chrome`, and a body template
bound through `layout`. Both are still read and rendered. `GET /v1/storefronts/
resolve?slug=<shop>` answers with the **materialized** kit — every part's sections
inlined and tagged `payload.zone: "top"|"bottom"` — which is a read shape, not
something you can write back.

**Reading the current state is three questions.** What the shop uses —
`GET /v1/storefronts/resolve?slug=<shop>`. What *this* resource uses and from
where — `GET /design/bindings/{type}/{id}` → `own[]` plus
`resolved{slot:{block_id, source}}`, `source` being `own` / `category:<id>` /
`shop` / `default`; a resolved `block_id: 0` with a non-`default` source is the
explicit "nothing" of §3.3. What is on the page — `curl <page> | grep -o
'data-vz-kit="[^"]*"'`.

### 3.6. Layouts (`theme`) and system pages

A **layout** ("Лейаут" in the admin, `theme` in the API) is a group-container
above the header and footer: `group_type:"theme"`, an empty `widgets` list, and
its **slots are ordinary bindings** with `resource_type:"theme"` and the group's
id as `resource_id`. Nothing new to learn: `PUT /design/bindings/theme/{id}` with
`header`, `feed`, `body`, `footer` — plus a body **per page kind**,
`body_product` · `body_category` · `body_news` · `body_article`, which beats the
layout's plain `body` for that kind. The layout itself does not inherit: its
panel shows only what it binds.

Which layout a page uses is its own short ladder on the slot `theme`:

```
this resource → the category chain → the shop → the earliest layout of the shop
```

So `PUT /design/bindings/category/648 {"slots":{"theme": 1837}}` switches a whole
section to another design; bind `theme` on `shop` (id `0`) to pick the shop-wide
one. `GET /design/bindings/...` answers with `resolved.theme` and marks slots that
came from the layout with `source:"theme"`. Compatibility is by construction: a
shop with no layout, or a layout that binds nothing, resolves through the same
rungs it always did (`theme_id: 0`, `theme_source: "default"` in `design.page`).
Deleting the earliest layout hands its role to the next one by id — the site
may change; assign another layout first.

**System pages** — the pages the storefront draws itself — are design resources
too: `resource_type:"system"`, `resource_id` from the registry
`cart=1 · checkout=2 · account=3 · orders=4 · wishlist=5 · search=6 · deals=7 · dashboard=8 · coupons=9 · notfound=10 · thankyou=11 · compare=12`,
slots `theme`, `header`, `footer` only (no body, no feed — the storefront owns
the body). Unbound, they inherit the shop's layout and chrome. The storefront
reads them through a public call:

```
GET /design/system/{code}?company_id=N     → { "design": { "page": { header, footer, theme_id, … } } | null }
```

`null` means the shop predates the group model — the storefront then draws its
legacy chrome. On checkout, cart, account and the other guarded zones an
author-owned (level 3) header renders **without its scripts**.

**A layout also owns a folder** — the site's shared CSS, fonts, scripts and
favicon, the things that must not be buried inside the header's html widget
(where only the header can see them). It is the release mechanism of
`own-markup.md` §7.1 addressed by the layout instead of a document id, and
`index.html` there is the **skeleton** of every page: `<head>` nodes, the `<body>`
class onto the page root, the body content before `</body>`; CSS is scoped to
`.vz-theme-{doc}`, the class of the page root, so writing `body {}` and `:root {}`
is how you style the site. The ten method/path operations, scopes, limits,
favicon generation, guarded zones and `vz:navigate` are listed once in
`own-markup.md` §7.2; the step-by-step walkthrough is
`/docs/webcoding` §2b.

**Stable addresses for the site's files.** `/_html/{doc}/{rid}/…` carries the
release number on purpose: a release is immutable, so that address can be cached
forever. The same thing makes it unusable wherever a link outlives the release —
an email to a buyer, a mail signature, an organisation card, somebody else's
site. A layout's folder therefore has a second, **unversioned** address:

```
GET /_site/<path inside the layout folder>      # /_site/logo.svg, /_site/favicon.ico
```

It resolves the way the site's header does — the shop's `theme` slot → the
earliest layout → its folder → the active release — so it always points at what
is on the site *now*. Three differences from `/_html/`, and all three follow from
the missing release number: the cache is `public, max-age=300` plus an ETag
instead of immutable; **the tenant host is mandatory** (addressing starts at the
host, and without one "the active layout" is undefined — a bare call to the API
is a 404); HTML pages of the release are not served, exactly as in `/_html/`. The
CSS scope is the same (`.vz-theme-{doc}`) — it is one file of one document, and a
second behaviour on a second address would be a trap. Use `/_html/` for links
inside the site and `/_site/` for links that leave it.

**An article gets a layout of its own.** `article` is a design resource like a
product or a category, so one article can be put on another design without
touching its rubric:

```
PUT /design/bindings/article/<id>    { "slots": { "theme": 1837 } }
```

The ladder is `the article → its rubric and the rubric's ancestors → the layout →
the shop → the default`. The rubric branch is `category`, **not**
`category_children`: an article lies inside a rubric the way a subsection lies
inside a section, so a binding made on the rubric *itself* does reach its
articles. Read the result back together with the article —
`GET /articles/by-slug/{slug}` answers with `design`; the admin
`GET /articles/{id}` does not resolve design and never carries it. Until an
article has bindings of its own the answer is what it always was, so nothing
moves under existing sites.

`body_article` is **not** a slot of the article. Bodies per page kind stay slots
of the **layout** (§3.6 above), because which body a kind gets is decided above
the article rather than by it — `PUT /design/bindings/article/{id}` with
`body_article` answers `DESIGN_SLOT_NOT_FOR_RESOURCE`.

**Level-2 menu panels come in the HTML.** The platform chrome is rendered on the
server, and the dropdown panels of `siteMenu` are printed into the page already —
closed, as `data-open="0"` — rather than built by JavaScript after hydration.
They are printed **within a node budget** (the storefront spends 60 nodes on
them): roots that fit arrive with the page, and a root that does not fit loads
its panel on the first click from `GET /categories?view=menu` — one request per
tab, shared with the search suggest. What this means for you: the menu is in the
`curl` output and therefore indexable; a panel that opens after a short request
on a huge catalogue is normal, not a bug; and **your own header is untouched** —
the budget and the toggle belong to the platform `siteMenu` widget, a custom
header built from your own markup is printed whole, as before.

### 3.7. Reuse the active theme's content grid

Before inventing gutters for a custom category block, read
`GET /design/bindings/category/<id>` → the resolved theme →
`GET /themes/<theme-id>/site` and its active release CSS. Use that theme's shared
content-container class on your markup root, paired with an unconstrained
platform wrapper: `props.blockWidth: "full"` and
`props.paddingSides: {"top":0,"right":0,"bottom":0,"left":0}`. Keep
`contentWidth` unset for this pairing. `blockWidth` expands the `.vz-box` band
(and leaves `.vz-inner` uncapped); `contentWidth: "content"` would cap the
inner container again, while `"full"` explicitly removes its cap.
`paddingSides` controls the band padding. Let the theme class supply the
content width and gutters once, without another local width/padding rule.
Layout styles reach level-1 shadows
(`/docs/own-markup` §7.2). Local selectors still cannot reach the outer wrapper.

For example, a theme stylesheet can define `.tl-section` with its content width
and responsive gutters. Using that class on the catalogue root aligns it with
the banner. Read the actual theme CSS; the example class is not a platform default.

## 4. Recipes

### 4.1. Custom header and footer on one section only

1. write the markup and upload it (file upload → `POST /html-documents`, level
   1) — see `own-markup.md`;
2. wrap each document in a block, `kind: "html"`;
3. create **two** groups (§3.1), `group_type: "chrome"` — one with the header
   block, one with the footer block;
4. `PUT /design/bindings/category/<id>` with
   `{"slots":{"header":<header group>,"footer":<footer group>}}`.

Header and footer are separate on purpose: bind only `header` and the shop's
footer keeps working underneath.

**Verify on two pages, not one.** `GET /design/bindings/category/<id>` must show
`resolved.header.source: "own"`; then `curl` your page **and** the home page and
compare — your markup on yours, the original everywhere else.

### 4.2. Sticky or fixed — choose the intended behaviour

For a header that stays in the page flow, use `vz-sticky` on the markup root
or `props.position: "sticky"` on its wrapped block. The historical failure below
was fixed on 2026-08-19; sticky support is in `/docs/own-markup` §5.7; CSS surfaces are in §7.2.
For the platform `siteHeader`, use its own `props.sticky: true`.

For fixed/absolute overlays, use `/docs/own-markup` §7.2: chrome has its own
query-container wrapper, and `container-type` alone does not establish the
fixed containing block in every browser. Test the actual ancestor styles and
scroll behaviour before promising viewport anchoring. Fixed reserves no height;
check overlap and dropdown clipping at desktop/mobile widths.

### 4.3. Drawing the product page with your own card

Build a `product` group whose list is your widgets (card, breadcrumbs, whatever
you want in whatever order) and bind it to slot `body`:

```
PUT /design/bindings/category_children/<id>   { "slots": { "body": <group id> } }
```

`category_children` reaches every product in the subtree without touching a single
product record — that is the point of the slot. One product only: bind to
`product/<id>` instead.

**The two-cards problem is gone.** Before the group model the page merged a
template with the product's own zone blocks, and the first own card *replaced* the
template one — so a hand-built selection on a product page could not be assembled
at all. Now the body is one group and the page is assembled server-side: a card
with an explicit `card.productId` draws that product **anywhere**, including on
another product's page, while a card without one takes the page's product.

Nothing needs rewriting per product any more, so the old recipe — clearing
`refs: []` in every product's `__page:top`, 107 products against a 240 writes/min
ceiling — is retired: it wipes the page's own blocks. `__page:top` / `__page:bottom`
are the blocks the owner placed around the inherited body (`/docs/components`
§4.7.1); leave them alone.

## 5. Silently ignored

| What | What actually happens |
|---|---|
| `vz-sticky` on a block inside a kit | the old inner-wrapper failure is fixed (2026-08-19). Check the current wrapper/overflow and the surface matrix in `/docs/own-markup` §5.7; §4.2 gives the current recipe |
| `hideSystemBlock` with your card bound via the legacy `slots.layout` | the whole technical layer goes, your bound block included — the page renders empty. On the group model this is not how you hide a body: drop the marker from the feed (§3.4) or bind `body` to `0` (§3.3) |
| `siteMenu.level2` with `source: "categories"` | no dropdowns at all: a flat strip of root items, and a flat list of every category without parents reaches the client. A tree appears only with `source: "custom"` and hand-built `customItems[].children` |
| `href` inside `siteHeader.links` / `siteFooter.columns` | the label renders, as a `<span>` — a dead link. The element must be `{label, url}` or `{label, target:{type,id}}` |
| `siteFooter` with **no `columns` at all** | not ignored, but not empty either: the footer falls back to one column of the shop's **first 4 or 5 root categories** (by section order; the count depends on the footer layout). Kept for shops that never configured a footer. A new root category shows there only if it is among the first ones — move it up (`POST /categories/move`) or set `columns` explicitly |
| `siteHeader` with **no `links`** | the header falls back to the **first 6** root categories, by section order. A seventh section is not in the header; the menu (`siteMenu` with `source: "categories"`) lists every published section that has `is_show_menu` on, while `source: "custom"` lists only its own `customItems` — a new section never appears there by itself |
| a category with `is_show_menu:false` | not ignored any more: the menu, the header's fallback links and that footer column all skip it **and its whole branch**. Default is `true`, so this is how you add a landing page without touching navigation — `/docs/catalogue` |
| `siteHeader.nameStyle.weight: "800"`, `siteMenu.labelStyle.size: 13` | clamped: 600 and 15 render |
| binding `slots.layout` to the home page category | accepted, stored, 200 — and the render does not change: the home body is drawn outside the block system (measured on category 106) |
| `GET /content-blocks/{id}/usage` on a group that nothing binds explicitly | `total: 0` for a group that draws every page of its kind. `usage` counts **direct bindings and manifest references**, never pages reached by inheritance, and the last rung of the ladder (§3.2) is not one. A group with `total: 0` may still be the one drawing your shop — check `resolved` on a real resource before deleting anything. The last rung answers `"source":"shop"` there, exactly like an explicit shop binding: `GET /design/bindings/shop/0` with an empty `own` tells them apart |

## 6. Limits

| Thing | Value |
|---|---|
| slots per write | 12 pairs max, replace-set semantics |
| `resource_type` | `shop` (id must be `0`) · `category` · `category_children` · `product` · `article` · `theme` (id = layout group) · `system` (id from the registry, §3.6) |
| slot names | `header` · `feed` · `body` · `footer` · `theme` — on `shop`, `category`, `category_children`, `product` and `article` alike; on a `theme` resource **also** `body_product` · `body_category` · `body_news` · `body_article`; on `system` only `theme` · `header` · `footer` (`DESIGN_SLOT_NOT_FOR_RESOURCE` otherwise); legacy `chrome` · `layout` |
| what fits a slot | every one of them needs a block with a `kind:"group"` section — a widget block is `DESIGN_SLOT_NEEDS_GROUP`. Beyond that: `feed` — a group labelled `layout` and nothing else, because its list holds groups and the body marker rather than widgets; `header`, `body`, `footer` — **any group except a feed or a layout**; `theme` — a layout group and nothing else. Legacy: `chrome` needs a `kind:"chromeKit"` block, `layout` a block whose name does not start with `__` |
| empty slot | `0` on the four new slots, `theme` and `body_*`; the legacy pair refuses it (`DESIGN_SLOT_EMPTY_UNSUPPORTED`) because the old resolver has no third state |
| rejection tokens | `DESIGN_RESOURCE_TYPE_UNKNOWN` · `DESIGN_SHOP_ID_NOT_ZERO` · `DESIGN_SLOT_UNKNOWN` · `DESIGN_SLOT_NOT_FOR_RESOURCE` · `DESIGN_THEME_UNKNOWN` · `DESIGN_SYSTEM_PAGE_UNKNOWN` · `DESIGN_SLOT_NEEDS_GROUP` · `DESIGN_SLOT_WRONG_GROUP_TYPE` · `DESIGN_SLOT_EMPTY_UNSUPPORTED` · `DESIGN_GROUP_TYPE_UNKNOWN` · `DESIGN_CHROME_NOT_KIT` · `DESIGN_LAYOUT_SERVICE_BLOCK` · `BLOCK_NOT_FOUND` (foreign resources are masked, not reported) |
| group nesting | one level: feed → groups → widgets. A feed inside a feed is refused |
| deleting a group | refused while a binding points at it (`DESIGN_GROUP_IN_USE`, also for a group block without a label) or, for `chrome`, `theme`, `product`, `category`, `news` and `article`, while it is the last of its label (`DESIGN_GROUP_LAST_OF_TYPE`): pages without a binding fall back to the earliest group of that label. A `layout` or `design` group in no binding and no list can be deleted even if it is the only one. Assign another group first, then delete |
| changing `group_type` | the same guard: a group in a binding or in another group's list keeps its label class — swap between ordinary labels (`chrome` · `product` · `category` · `news` · `article` · `design`) freely, but clearing the label or moving to or from `layout`/`theme` is `DESIGN_GROUP_IN_USE`; relabelling the last group of a fallback label is `DESIGN_GROUP_LAST_OF_TYPE` |
| write rate | 240/min — the ceiling when rewriting product zones in bulk |
| after a successful `PUT` | a `design.changed` webhook fires from the prod contour; dev edits fire nothing until published |

## 7. How this was verified

- **2026-09-13** (the §3.6 tail), by source in both repositories plus the stand
  run recorded in the layouts tech plan: `/_site/` answers `200` with
  `public, max-age=300` and an ETag for a file of the active layout and `404`
  for a foreign host, a missing file, a bare API call without the tenant host
  and a path that climbs out of the folder; an article with no bindings of its
  own resolves to the shop's layout, a binding on its rubric moves it, and a
  binding on the article itself beats the rubric; the chrome's markup is frozen
  by a snapshot test, so the move to the server changed bytes only inside the
  islands' `data-` attributes.
- **2026-08-21** (group model), live calls against the stand plus page reads in a
  browser: a shop born on groups renders header, breadcrumbs, heading, listing and
  footer; binding `header` and `footer` to `0` on one product removes both from
  that page and leaves every other page alone; restoring brings them back. A shop
  still on `chromeKit` was re-read in the same run and its chrome was untouched.
  The two-cards fix was measured on a product carrying both its own card and a
  card pinned to another product — both render, in order.
- **2026-08-19** (everything below), live calls against `api.vizen.shop` (shop `kiberpank`, company 11,
  PAT on the `prod` contour, `writes_to_live: true`) plus measurements in real
  Chrome on `kiberpank.vizen.shop`.
- The `kind:"html"` in a kit fact is a **probe result**: an html block was written
  into `kit.top` of a live kit and the page re-read — it rendered, with
  `data-vz-kit` stamped on it. No guide predicted it.
- Sticky: `getBoundingClientRect` on the header after scrolling; the fixed
  workaround re-measured at seven widths, spacer against header height. Two cards,
  the empty page after `hideSystemBlock` and the 107-product blast radius come from
  the same run, which kept a rollback snapshot of every zone touched.
- Error tokens, resolution order and the replace-set semantics of `slots` were read
  from the handler source (`internal/api/catalog/set_design_bindings.go`,
  `internal/core/services/design/resolve.go`), not from live calls. The home-page
  finding (category 106) and the `usage` finding (block 2089) come from
  neighbouring runs on the same shop the same week.
