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

> **Резюме по-русски.** Хром и скелет страницы. С 2026-08-21 состав оформления
> хранится ОДНИМ способом — группой ссылок на виджеты, — а страница собирается
> из четырёх слотов: шапка · лента · тело · футер. Шапка и футер разделены:
> заменить одну, не трогая другую, теперь можно. У слота три ответа, а не два:
> «наследовать», «нет» и «эта группа» (§3.3) — именно так гасится шапка, и
> флагов-выключателей для этого больше не заводится. С 2026-09-12 над хромом
> появился ЛЕЙАУТ (§3.6): группа-контейнер со своими слотами, своей папкой
> файлов и кода и оформлением системных страниц (корзина, кабинет…). С
> 2026-09-13 у папки лейаута есть НЕверсионированный адрес `/_site/<путь>`,
> статья стала ресурсом оформления, а хром (включая панели меню уровня 2)
> печатает сервер.

**Status:** current · **Verified:** 2026-08-21, live calls on the stand + browser;
§3.6 — 2026-09-12, по коду (домен, резолвер, хендлеры); §3.6 хвост (`/_site/`,
лейаут у статьи, серверный хром и меню уровня 2) — 2026-09-13, по коду обоих репо
**Owner:** agent-api · **Serves:** `GET /docs/chrome`
**Related guide:** `/docs/webcoding` §16.2 (own header, one page)
**Skill:** `vizen-chrome`

> **Магазины старой модели.** Комплект `chromeKit` и пара слотов
> `chrome`/`layout` продолжают работать — их читает та же ручка, и магазин, где
> они настроены, ничего не теряет. Новый код пишет группы; старое описание
> оставлено в §3.5 как справка по тому, что вы можете встретить в чужих данных.

## 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 | the look is bounded by props (~30 on `siteHeader`); `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 | full freedom; sticky needs a workaround (§4.2), and a dropdown cannot overhang the block |
| 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] } } } ] } }
```

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

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

| `resource_type` | Applies to |
|---|---|
| `shop` (id must be `0`) | shop-wide default |
| `category` | that category page — or a `type:"page"` page |
| `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.

### 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`,
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. Seven endpoints, with `catalog:write` +
`storage:write` and owner/admin: `GET /themes/{id}/site` ·
`POST /themes/{id}/site/releases` · `…/releases/zip` · `…/releases/{rid}/publish`
· `…/releases/{rid}/validate` · `GET …/releases[/{rid}]` ·
`POST /themes/{id}/site/rollback`. Fields, limits, favicon generation, the
guarded zones and `vz:navigate`: `own-markup.md` §7.2; the Russian walkthrough is
`/docs/webcoding` §2б.

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

## 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. Making a custom header stick — a workaround, and you must say so

`vz-sticky` does not work here (§5). What holds is a spacer plus a fixed header:

```css
.hdw { --hdh: 126px }               /* the header's real height, per breakpoint */
.hdw__spacer { height: var(--hdh) }
.hd { position: fixed; left: 0; right: 0; top: 0; z-index: 2147482000 }
```

The height is measured, not guessed — on the measured run 126/165/153/91 px across
five breakpoints. It is a crutch: `position: fixed` reserves no height, so the
spacer rots the moment the header's contents change. **Say so to the owner.**

**Verify:** at scroll 2000 the header's top reads 0 at every width tested
(375/520/700/900/1024/1440/1600) and the spacer matches the header's height to the
pixel. A mismatch shows as a jump or a gap — never as an error.

### 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. If you still meet those manifests in an unmigrated shop,
they are read for compatibility and nothing else.

## 5. Silently ignored

| What | What actually happens |
|---|---|
| `vz-sticky` on a block inside a kit | the key honestly applies `class="sticky top-0 z-30"` — to a div **inside** the block wrapper, whose parent is exactly as tall as the header. Nowhere to travel: measured, at scroll 2200 the header's top read **−2200** |
| `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}}` |
| `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 **bindings**, 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 |

## 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 | `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`) or while it is the last of its label (`DESIGN_GROUP_LAST_OF_TYPE`) — the shop always keeps something to draw a page with. Assign another group first, then delete |
| 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.
