# Troubleshooting: the API said yes, the site says no

> **Резюме по-русски.** Диагностика «ответ 200, а на сайте ничего»: сначала
> перечитать объект, потом открыть страницу браузером; таблица тихих отказов и
> чек-лист приёмки. Отдельно — правило «находка = замер + условия замера»: находка
> «`siteMenu.level2` не работает» оказалась неверной, условия замера не записали.
> 2026-08-19 та же болезнь нашлась в ГЕЙТЕ: зелёный тест сторожил форму кода, а
> провода между двумя её концами не было — §7 про это.

**Status:** current · **Verified:** 2026-08-20, live calls + source measurements
**Owner:** agent-api · **Serves:** `GET /docs/troubleshooting`
**Skill:** `vizen-troubleshoot`

## 1. What you can do here

Tell apart three states that look identical from the client: a write rejected in
substance, a value stored but read by no widget, and a change that is really on
the page but invisible to your selector. This area also holds the acceptance
checklist every storefront change goes through before the owner is told it is
done. A 200 means the write was stored — not that the value is used.

## 2. Decide first — the order of diagnosis

Two checks, in this order, before you change anything:

| Step | What you do | What the result means |
|---|---|---|
| 1 | **Re-read the object** — `GET /content-blocks` and look at your value | gone or changed → the write was **rejected in substance**: wrong nesting level, wrong shape, value outside the enum. Fix the write, not the widget. |
| 2 | **Open the page in a browser** | present in the object but absent on screen → **the widget does not read that prop**. Stop tuning the write; go to §3. |

The other order costs more: a lost value looks exactly like an ignored one.

⚠️ **Level-1 selectors must pierce Shadow DOM.** Level-1 own markup renders inside
a shadow root (host `div.vz-radius`), and a plain `document.querySelector` does
not see it. An empty result is not evidence that your markup is missing. A
transferred folder (level 3) is real page DOM and needs none of this. Walk
recursively:

```js
const deepQuery = (sel, root = document) => {
  const hit = root.querySelector(sel); if (hit) return hit;
  for (const el of root.querySelectorAll('*'))
    if (el.shadowRoot) { const r = deepQuery(sel, el.shadowRoot); if (r) return r; }
  return null;
};
```

## 3. Silently ignored — the known cases

| Symptom | Cause | Fix |
|---|---|---|
| a filter returns the whole catalogue | the parameter was written without the `filter.` prefix; `?category_id=168` is not a field of the request, so it is swallowed and `total` stays at the full count | `filter.category_id=168` |
| a value outside the enum draws the default † | unknown enum values are accepted silently, no error | take values from `GET /docs/widgets` |
| a menu or footer link renders as plain text | `href` is not a supported key in `siteHeader.links` / `siteFooter.columns`; the label is printed inside a `<span>` and the link is dead | `{label, url}` or `{label, target:{type,id}}` |
| a dropdown or a modal is cut off at the block edge | the shadow host `div.vz-radius` clips **when `props.overflow` is absent**. The axis does reach that node — the block renders as `clip={props.overflow !== 'visible'}` — but the band's default is `"visible"` while the shadow host's is `clip`, so for an html block "key absent" is not "visible". `/docs/widgets` now says so in the axis text; older copies of it do not | set `props.overflow: "visible"` on the block. `position: fixed` is no longer needed for this (`own-markup.md` §5.1, §5.6) |
| a custom header does not stick — **and it is your own markup** | it should stick now: the wire was built 2026-08-19, and both `props.position: "sticky"` and `vz-sticky` reach the band on page zones, on layout zones (the chrome-kit case) and on `columns` children. If it still does not: the block sets `props.overflow: "clip"`, and a clipping ancestor defeats sticky | drop `overflow: "clip"` on that block, or move the clipping inside your markup. `position: fixed` plus a hand-measured spacer is **no longer needed** — stop prescribing it |
| a **platform** header, menu or footer does not stick from `props.position` | those kinds draw their own band and the block wrapper never runs for them (`CHROME_KINDS`), so no wrapper style is produced. Nothing is stored wrong; nothing reads it | the header has its own switch: `props.sticky: true` on the `siteHeader` section. `siteMenu` and `siteFooter` have none — put your own markup in the kit instead (`own-markup.md` §5.7) |
| a **level-1** widget renders without the site's fonts and colours | it used to: a shadow root isolates in both directions. Since 2026-09-13 the layout's stylesheets are put inside the shadow root and the markup is wrapped in `.vz-theme-{doc}` there, so the theme does reach it. If it still looks bare — the page has no layout at all, or the rule is a compound selector (`body.dark`), which is never rescoped onto the page root | bind a layout to the shop (`chrome.md` §3.6), or move that class onto your own root element (`own-markup.md` §7.2) |
| a level-2 menu panel opens only after a short pause | **not a bug.** The server prints menu panels into the HTML within a node budget; a root that did not fit loads its panel on the first click from `GET /categories?view=menu`, one request per tab | nothing to fix. Your own header is unaffected — the budget belongs to the platform `siteMenu` |
| a block sticks on the live site but not in the admin editor | the editor canvas does not ask the wrapper for stickiness; the storefront does | check on the shop URL, not in the preview. Do not "fix" it |
| `DELETE` fails with `invalid request body` † | the endpoint requires a body | send `{}` |
| two product cards on one product page † | a bound `layout` block plus the resource's own bundled card | `chrome.md`, card replacement |
| `productListing` `props.container.fill` accepted, strip unpainted † | documented in `wrapper.axes`, not implemented: neither `--vz-fill` nor a class appears in the markup; five forms of the value were tried | none today |
| a type style renders at a different value † | `siteHeader.nameStyle.weight: 800` renders 600; `siteMenu.labelStyle.size: 13` renders 15 — the widget does not apply what you sent | none today; own markup if the exact value matters |

† carried over from the previous run's report and **not re-measured** on
2026-08-19 — reported, not proven. Anything you find that is not listed belongs
here as a named case, with the configuration you measured on (§4).

**Closed since the previous run, do not re-report:** own markup and any other
non-chrome block not sticking — `props.position: "sticky"` and `vz-sticky` both
work since 2026-08-19 (§7); what survives is the narrower list in §3, and the
answer is never `position: fixed` any more. Also closed: the resizer answering
`HEAD` with 404. It answers `HEAD` and `GET` alike now — measured 2026-08-20, both 200
`image/webp` on `api.vizen.shop` and on the shop host. `HEAD` is a fine way to
check that an image URL resolves. Also closed: "the page scope carries no list of
products" (`products[]` is live — `own-markup.md` §2) and "the `overflow` axis
never reaches the node that clips" (it does — row 1 of this table).

## 4. A finding is a measurement plus the conditions of the measurement

The rule this area exists to enforce, bought with a real mistake.

**The claim.** The previous run reported: "`siteMenu.level2` is declared; with
`source: "categories"` there are no dropdowns; the client gets a flat list of all
83 categories with no parents." It was filed as a platform defect, and
`vizen-webcoding-contract.json` still lists it in `silently_ignored`. That entry
is wrong and is superseded here.

**What re-measurement showed.** The menu block (id 2085) was configured
`source: "custom"`, `level2: false`, three hand-written items — a mode with no
dropdowns by design. The run tested a switched-off feature and reported on the
feature. A reversible experiment then set block 2085 to `source: "categories"`,
`level2: true`: the "Каталог" item rendered as `<button>` instead of `<a>` — an
item becomes a button exactly when `level2 && children.length > 0`, so the
children did arrive — and a click opened the panel with **82 sub-section links**,
tree complete, parents in place. The block was returned to `custom` /
`level2: false` / three items and the rollback was verified.

**The grain of truth is in the documentation, not the code.** The widget schema
says sub-sections drop down on **hover**; the panel opens on **click**, and a real
mouse movement does not open it. The run checked hover, saw nothing, and
condemned the mechanism. An error in a description costs what an error in code
costs — this one sent a neighbouring chat into reworking a whole direction.

**The rule.** "X does not work" and "X does not open on hover with
`source: categories`, `level2: true`" are different statements, and the first
sends a whole direction into rework. Record the configuration you measured on:
block id, the props as read back, the URL, the viewport width, hover or click. Of
seven claims put through this review six held and one fell — and the one that
fell was the only one derived from reading code instead of measuring.

## 5. Acceptance checklist

Run all of it before telling the owner you are done.

- [ ] the pages you did **not** touch are unchanged — the home page and two
      sections; `curl` shows the same `data-vz-kit` and none of your markup;
- [ ] the page opens **by direct URL** and **by a link clicked inside the site** —
      these are different code paths and scripts behave differently on each;
- [ ] after an in-site click: dropdowns open, modals open, timers run (platform
      handlers must re-initialise, and that is where they usually do not);
- [ ] the header holds at 375 / 520 / 700 / 900 / 1024 / 1440 / 1600, not only at
      your width; top of a sticky header = 0 while the page is scrolled;
- [ ] no `position: fixed` header and no hand-measured spacer anywhere in what
      you shipped — sticky works through `props.position` / `vz-sticky` since
      2026-08-19, and a spacer is now a defect, not a workaround;
- [ ] no horizontal scrolling on the page body at any of those widths;
- [ ] the cart really accepts an item — press, watch the counter grow, check the
      button takes `vz-state="in-cart"`;
- [ ] your text is present in the **raw** HTML: `curl <page> | grep '<phrase>'`.
      If it is not there, search engines never see it;
- [ ] images are served through the resizer (`/w/<width>/webp/…`), not as
      originals from `storage.yandexcloud.net`;
- [ ] the owner is told plainly what is a workaround — `position: fixed`,
      hand-counted spacers, prices frozen into own markup — and what is not;
- [ ] at least one screenshot from a real browser.

## 6. Measure, do not guess

Install a browser in the scratchpad and drive it. Status codes cannot tell you
whether a dropdown opens or a header sticks: three edits out of eight in the
previous run looked successful by status code and changed nothing on the site.

In that run **18% of steps went on browser checks, and they were the cheapest 18%
of the build** — every finding that survived review came from a measurement, and
the single one that did not came from reading code instead. The same run spent
21% discovering what was possible and 12% redoing work after a change of course:
33% pure loss. Diagnosis is not the expensive part. Guessing is.

## 7. How this was verified

Live shop `kiberpank.vizen.shop`, 2026-08-19:

- storefront `/neon-grid` pulled and taken apart (683 KB of markup); menu,
  categories, products and chrome kit 2087 queried over the API;
- resizer measured on one URL: `HEAD` → 404, `GET` → 200 `image/webp`
  (**superseded 2026-08-20 — see below**);
- wrapper chain measured in a real browser through shadow roots —
  `div.vz-box` `overflow: visible` → `div.vz-inner` `overflow: visible` →
  `div.vz-radius` `overflow: clip` + shadow root (the chain still holds; what
  changed is that the last node is now switchable — see below);
- `siteMenu.level2` re-measured by a reversible write to menu block 2085, 82
  sub-section links counted in the open panel, block rolled back and the rollback
  checked;
- the `filter.` prefix is confirmed against `api/catalog/catalog.proto:2768-2783`:
  `GetProductsRequest` carries a nested `Filter` message with `category_id`, so
  the query parameter is `filter.category_id` and a bare `category_id` is not a
  field of the request;
- rows marked † in §3 come from the previous run and were **not** re-measured.

Re-measurement 2026-08-20 — three rows of §3 were stale, and one of the three was
stale in the opposite direction from what the checklist assumed:

- **the resizer and `HEAD`.** `curl -I` and `curl` on the same live URL
  (`/w/640/webp/vizen-prod-files/sunset/view/product/e3/83/…​.png`) answer **200
  `image/webp` both ways**, on `api.vizen.shop` and on the shop host alike. The
  cause of the old 404 is in the source and is fixed: `imgserve.go` now registers
  `mux.Head("/w/{width}/*", resize)` next to the `Get`, because chi does not
  derive `HEAD` from `GET`. Gate: `storefront/test/image-slicing.test.mjs` ·
  "HEAD у нарезчика зарегистрирован рядом с GET". The row is deleted, not edited:
  there is no silent failure left to describe;
- **the `overflow` axis.** `SectionHtmlInline.tsx` and `SectionHtml.tsx` both
  render the shadow host as `clip={section.props?.overflow !== 'visible'}`, so the
  axis reaches the node that actually clips. What remains true — and is now the
  row's content — is that the effective default for an html block is `clip` while
  `/docs/widgets` advertises `"visible"`. Gate:
  `storefront/test/wrapper-sticky-clip.test.mjs`;
- **the custom header.** Was still not sticking at that hour, and not for the
  documented reason: a repo-wide search found `htmlSticky` **written** in
  `storefront/src/islands/ZoneSections.tsx` and read nowhere, and `<SectionBox`
  in eight places, **none** of which passed `sticky`. The branch existed; the
  wire did not. ⚠️ `wrapper-sticky-clip.test.mjs` was **green** through all of
  it, because it asserts the *shape* of the source, not that anything connects
  the two ends. A shape gate cannot see a disconnected wire — the §4 lesson,
  applied to gates instead of to findings.

The wire was built the same day, and the row in §3 was rewritten from the result.
What changed, measured on rendered markup rather than on source shape:

- `sectionSticky()` is now asked by the section list (`packages/sections/src/PageSections.tsx`)
  and by the nested blocks of the `columns` widget, so `props.position: "sticky"`
  and `vz-sticky` both reach the band. The chrome-kit path goes through the same
  list, which is why a custom header in `kit.top` sticks;
- the order of branches inside `wrapperFrameStyle()` changed with it: the
  background layer used to set `position: relative` **last** and overwrote
  `sticky`, so a band with a background never stuck even where the wire existed.
  That one was invisible to every gate reading source text;
- three surfaces still do not ask, and §3 names each: the three chrome kinds
  (the wrapper does not run for them at all), `vz-sticky` inside a `columns`
  child, and the editor canvas;
- the new gate reads the `style` attribute of markup rendered through
  `react-dom/server` (`storefront/test/wrapper-render.test.mjs`), which is the
  only kind of gate that could have caught either defect.

## 8. Negations in this document (rule §3.9)

Every row of §3 is a negation — that is what the area is made of — so this table
is the area's own regression list. A row without a gate is re-measured after each
wave; a row with a gate falls the day the gate goes red.

| The claim | Kept true by |
|---|---|
| a bare `category_id` is not a field of the request (§3) | source-pinned: `api/catalog/catalog.proto` `GetProductsRequest.Filter`. No gate — a rename of the nested message would leave this text standing |
| unknown enum values are accepted silently † (§3) | after-wave pass — carried over, never measured here |
| `href` is not a key of `siteHeader.links` (§3) | after-wave pass — no gate |
| the shadow host clips when `props.overflow` is unset (§3) | gate `storefront/test/wrapper-sticky-clip.test.mjs` — it pins the expression that *is* the default |
| `vz-sticky` and `props.position` reach the band on page zones, layout zones and `columns` children (§3) | gate `storefront/test/wrapper-render.test.mjs` — it reads the `style` attribute of rendered markup. This is the positive claim; it replaced a negation, and the gate replaced the shape gate that could not see the wire |
| the three chrome kinds, `columns` + `vz-sticky`, and the editor canvas still do **not** ask (§3) | after-wave pass, by **search, not by gate** — `CHROME_KINDS` early-returns in `SectionBox`, the `columns` call site passes no `htmlSticky`, and the canvas call sites pass no `sticky` at all. Nothing here goes red when one of them is wired up, so re-measure before repeating the claim |
| `DELETE` needs a body † (§3) | after-wave pass — carried over, never measured here |
| `props.container.fill` is documented and not implemented † (§3) | after-wave pass — carried over; owned by `widgets.md` |
| type styles render at a different value † (§3) | after-wave pass — carried over; owned by `widgets.md` |
| the resizer answers `HEAD` (§3, closed row) | gate `storefront/test/image-slicing.test.mjs` · "HEAD у нарезчика зарегистрирован рядом с GET" — the only reason this correction cannot rot back |
| `document.querySelector` does not cross a shadow root (§2) | platform behaviour of the DOM, not ours — nothing to gate |

⚠️ **The lesson this table exists to carry.** Two of the entries above are green
gates that would not have caught the defect they guard: a gate that reads the
source for a *shape* proves the shape, and a broken wire has the right shape at
both ends. When a negation matters, re-measure it the way §4 demands — on the
running thing, with the conditions written down — and treat the gate as a
reminder, not as the proof.
