> ## Documentation Index
> Fetch the complete documentation index at: https://docs.avocadostudio.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Coverage checks

> Two checks that decide whether an integration is finished: does the rendered page offer every field the manifest declares, and is the property panel intelligible to a person.

An integration can build cleanly, type-check, serve a valid block manifest, render every page, and still hand your marketing team a surface they cannot work in. Nothing in your toolchain sees that. The compiler does not know what a hover pill is, and the type system has no opinion about a property panel whose list rows all read `Item 4`.

These two checks do. They need no browser, no screenshot and no model call — everything they compare is already in the manifest, the rendered HTML and your own content.

<CardGroup cols={2}>
  <Card title="editableCoverage" icon="mouse-pointer">
    **Does the rendered page offer each field?** For every field the manifest declares, is there an element carrying a `data-editable-target` marker — so a person can click it in the preview and edit it in place.
  </Card>

  <Card title="panelCoverage" icon="sliders">
    **Is the property panel intelligible?** Can every list row be told apart, do polymorphic branches narrow to their own field set, and is anything in your content described by nothing at all.
  </Card>
</CardGroup>

## Why there are two

The property panel is built from the block manifest and never looks at the rendered page. The preview overlay does the opposite: it finds every job it has by walking `[data-editable-target]` in the DOM.

So the two surfaces can disagree, permanently and silently. A field nobody marked still appears in the panel, still edits and still saves — it is simply absent from the preview, with no inline editing, no hover pill and, for an image, no **Change** button. Nothing logs a warning, because from each side everything looks right.

The reverse failure is just as quiet. Every marker can be in place while the panel that same manifest produces lists five rows called `Item 1` through `Item 5`, narrows no polymorphic branch, and omits a prop your content is full of.

```mermaid theme={null}
flowchart LR
    Manifest["<b>Block manifest</b><br/>what the panel will draw"]
    Page["<b>Rendered page</b><br/>which fields carry a marker"]
    Content["<b>Your pages</b><br/>what the rows really hold"]

    Manifest -- "editableCoverage" --> Page
    Manifest -- "panelCoverage" --> Content

    style Manifest fill:#7ED957,stroke:#14532D,color:#0a0a0a
    style Page fill:#f1f5f9,stroke:#64748b,color:#0a0a0a
    style Content fill:#f1f5f9,stroke:#64748b,color:#0a0a0a
```

Each check is a disagreement between the manifest and one of the two things it is supposed to describe. Neither can see the other's failures.

## editableCoverage

`extractMarkedBlocks(html)` reads a rendered page and returns one `MarkedBlock` per block wrapper: its `blockType`, its `blockId`, and the field paths marked inside it. `editableCoverage(manifest, blocks)` compares that against what the manifest declares.

```ts theme={null}
type MarkedBlock = {
  blockType: string
  blockId?: string
  paths: string[]
  /** Paths whose marker sits on a void element — an <img>, most of the time. */
  voidPaths?: string[]
  /** The block's props, when you have them. Supply these — see below. */
  props?: Record<string, unknown>
}

type EditableCoverage = {
  expected: number
  marked: number
  gaps: BlockCoverageGap[]
  unknownBlockTypes: string[]
}
```

<Tip>
  **Always set `props`.** A field with no value draws nothing, and an element that was never drawn cannot carry a marker. Without the props, a prop that happens to be empty across your whole site is indistinguishable from one nobody instrumented, and gets reported as a gap that cannot be closed. With them, the check narrows the denominator to fields that actually had content — and narrows a polymorphic list row to its own branch.
</Tip>

A gap comes back as one `BlockCoverageGap` per block type, not per instance — a missing marker is a property of the component, not of the one block that happened to render first:

```ts theme={null}
type BlockCoverageGap = {
  blockType: string
  exampleBlockId?: string
  missing: string[]              // top-level fields that draw something and carry no marker
  missingItemFields: string[]    // as "cards[].title", for a list present but under-marked
  markedOnVoidElement: string[]  // marked where no button can ever mount
  unmarkedLists: string[]        // no marked path at all — empty list, or uninstrumented
}
```

`formatEditableCoverage` turns that into something you read in a terminal:

```
editable fields marked: 2/3 (67%)
  TwoColumn (e.g. b1)
    marked on a void element (no button can mount)  left[].imageUrl
```

`unmarkedLists` is deliberately kept out of the score. An empty list and an uninstrumented list look identical from outside the page, so it is reported and not counted.

## panelCoverage

`panelCoverage(manifest, pages, options?)` takes your manifest and your actual pages — anything shaped like `{ slug, blocks: [{ type, props }] }`, which is what `GET /api/editor/pages` already returns.

```ts theme={null}
type PanelCoverage = {
  rowsExamined: number   // list rows across every page
  rowsLabelled: number   // rows the panel can label from their own content
  findings: PanelFinding[]
  unknownBlockTypes: string[]
}
```

It resolves metadata through `resolveEditorBlockMeta` and rows through `resolveListItemFields` — the same functions the property panel itself uses — so a finding is the panel's real behaviour rather than a model of it.

Pass `builtinTypes` to get the collision findings:

```ts theme={null}
import { getAllBlockMeta } from "@avocadostudio-ai/site-sdk/blocks"

panelCoverage(manifest, pages, { builtinTypes: getAllBlockMeta() })
```

Without it, collisions are simply not reported — an absent input is never evidence.

## The seven finding codes

Findings are aggregated by code, block type and path, and sorted most-actionable first. Read them in this order: a colliding type usually explains several of the findings under it, so you fix one cause instead of six symptoms.

<AccordionGroup>
  <Accordion title="colliding_type — your block name is also one of Avocado's">
    Your manifest registers a type name that exists in the editor's own registry with a different shape. **Anything your manifest does not declare explicitly is taken from the built-in**, which is how a site ends up with someone else's labels on its own fields.

    The detail names both sides: `only in yours: heading; only in the built-in: variant, headingLevel, right`.

    **To clear it:** rename your block type to something the built-in catalogue does not use, or declare every field and list field explicitly so nothing falls through to the built-in.
  </Accordion>

  <Accordion title="incomplete_polymorphism — half a discriminated union">
    A list declares a `discriminator` and no `itemFieldsByType`, so nothing narrows; or it declares `itemFieldsByType` and no `discriminator`, so no branch is ever selected. This is a static check, run once per declared type, independent of your content.

    **To clear it:** declare both, or neither.
  </Accordion>

  <Accordion title="unmatched_branch — a row whose kind has no branch">
    The row carries a discriminant value with no entry in `itemFieldsByType`, so it is edited against the union of every branch. The person editing it sees fields from shapes this row is not.

    The path names the value: `left[].type=quote`.

    **To clear it:** add the branch, or stop producing that discriminant value in your content.
  </Accordion>

  <Accordion title="unlabelled_row — the panel calls it Item N">
    No text, rich-text, alt-text or image field on the row holds a value, so the panel falls back to `Item 4`. The row exists in the content and is unidentifiable in the panel — a person scanning a list of them cannot tell which is which.

    Row labels are derived in order: the first text or rich-text field with a value, else an `imageAlt` with a value, else the filename of the first image, else the fallback. The discriminator is never used, because the panel already draws it as a `[branch]` prefix.

    **To clear it:** give the row a field somebody would recognise it by, or declare the field that already holds one as `text`, `richtext`, `image` or `imageAlt` so the labeller can see it.
  </Accordion>

  <Accordion title="orphan_prop — content described by nothing">
    A prop your content holds that neither `fields` nor `listFields` describes. The panel cannot show it and cannot edit it, and the planner is never told it exists. Reported both for block props and for row props, with an example slug.

    Identity keys are exempt: `id`, `_key` and `_type` on a block, and `id`, `_key` and the discriminator on a row.

    **To clear it:** declare the prop, or — when it belongs to the system storing your content rather than to anyone editing it — declare it `internal: true`. Underscore-prefixed keys are treated as internal automatically, so a Storyblok `_uid` needs nothing.
  </Accordion>

  <Accordion title="filename_row_label — labelled by a camera">
    The row fell through to an image filename, which means the alt field beside it is empty. One finding covering two costs: the panel row is named `20250904_075546.webp`, and the image ships to readers with no description.

    **To clear it:** write the alt text. It names the row and describes the image to a screen reader in the same edit.
  </Accordion>

  <Accordion title="phantom_field — declared for rows that never have it">
    A field declared for a branch of rows that no row of that branch has ever had a value for. Panel noise: a control for something that is not there. Reported once per branch, listing every phantom key.

    Internal fields are never phantoms — declaring one is a statement about what the prop *is*, not a promise that a row holds it.

    **To clear it:** remove the declaration, or narrow it to the branch that actually uses the field.
  </Accordion>
</AccordionGroup>

Here is what a real run looks like with six of the seven present:

```
list rows the panel can label: 2/3 (67%)

colliding_type:
  TwoColumn — the editor also ships a built-in "TwoColumn" with a different shape
  (only in yours: heading; only in the built-in: variant, headingLevel, right).
  Anything your manifest does not declare explicitly is taken from the built-in.

unmatched_branch:
  TwoColumn.left[].type=quote (e.g. /pricing) — no itemFieldsByType entry for "quote",
  so this row is edited against the union of every branch

unlabelled_row:
  TwoColumn.left[].type=quote (e.g. /pricing) — the panel labels this row "Item N" …

orphan_prop:
  TwoColumn.tracking (e.g. /pricing) — held in content, described by neither fields
  nor listFields — the panel cannot show or edit it

filename_row_label:
  TwoColumn.left[].imageAlt (e.g. /pricing) — labelled by image filename because
  "imageAlt" is empty …

phantom_field:
  TwoColumn.left[].image — declared for these rows and never present in one: imageAlt, caption
```

## What counts, and what does not

The denominators decide whether 100% is reachable. A check that reports gaps nobody can close is a check everybody switches off, so both of these are narrowed on purpose.

**`editableCoverage` expects a marker only for a field that something on the page draws.**

| Rule                                                     | Effect                                                                                                                                                                                                                                                                                                    |
| -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Only `text`, `richtext` and `image` kinds count          | `enum`, `boolean`, `number`, `color` and `headingLevel` are settings, not things on the page. `url`, `link` and `file` are attributes on an anchor whose *label* is the text field beside them. `imageAlt` is an attribute on the image, and `reference` is a pointer the panel shows and never rewrites. |
| `inlineEditable: false` is honoured                      | A text field the site says is not typed into on the page — an anchor id, a slug fragment, a machine value typed as text. Not consulted for images, where the marker carries the **Change** button rather than a cursor.                                                                                   |
| `panelOnly: true` takes the field out of the denominator | There is no element to mark. A `sectionId` becomes an HTML `id`; a video's `poster` and an input's `placeholder` are attributes. The panel is the whole of their UI.                                                                                                                                      |
| `internal: true` is excluded                             | Not content at all. Underscore-prefixed keys (`_uid`, `_key`, `_type`) are internal automatically and cannot be un-declared.                                                                                                                                                                              |
| The discriminator of a polymorphic list is excluded      | It decides which shape the row is; it is not something anyone edits.                                                                                                                                                                                                                                      |
| A field with no value anywhere you measured is excluded  | Only when you supply `props`. Nothing drew it, so nothing could have marked it.                                                                                                                                                                                                                           |

**`panelCoverage` scores list rows, not fields.** Its percentage is rows the panel can label out of rows examined; everything else comes back as a finding. An `internal` field is never used as a row label, is never an orphan, and is never a phantom — declaring the prop internal is exactly how you take it out of both directions at once.

<Note>
  `panelOnly` is what made 100% reachable on `editableCoverage`. Before it existed, a correct integration reported 88% and anyone gating on the number had to hard-code a magic threshold — at which point a real regression that drops one marker while a new field adds another slips straight through.
</Note>

## Three ways to run them

<CardGroup cols={3}>
  <Card title="Your own check script" icon="terminal">
    `@avocadostudio-ai/site-sdk/coverage`. The one that matters most — most adopters never touch an agent, and this is the version that runs in your CI on every branch.
  </Card>

  <Card title="The MCP tool" icon="robot">
    `avocado-check-editing-surface`, for an agent to call before it reports an integration done.
  </Card>

  <Card title="The formatted report" icon="file-lines">
    `formatEditableCoverage` and `formatPanelCoverage`, for a person to read in a terminal or a CI log.
  </Card>
</CardGroup>

Everything is exported from one subpath:

```ts theme={null}
import {
  extractMarkedBlocks,
  editableCoverage,
  formatEditableCoverage,
  panelCoverage,
  formatPanelCoverage,
} from "@avocadostudio-ai/site-sdk/coverage"

import type {
  MarkedBlock,
  BlockCoverageGap,
  EditableCoverage,
  PanelCoverage,
  PanelFinding,
  PanelFindingCode,
} from "@avocadostudio-ai/site-sdk/coverage"
```

It is a separate entry point from `@avocadostudio-ai/site-sdk/markers` on purpose: nothing a page renders should pull an HTML scanner into its bundle.

### The MCP tool

`avocado-check-editing-surface` runs `panelCoverage` — the half an agent cannot otherwise see. It reads the manifest from the orchestrator (the process that runs your site's `registerBlocks()`), reads your pages, and returns both the structured findings and the formatted report.

| Argument                   | Meaning                                                                  |
| -------------------------- | ------------------------------------------------------------------------ |
| `slugs`                    | Pages to examine. Defaults to every slug the site has.                   |
| `includeBuiltinCollisions` | Report type names that collide with Avocado's built-ins. Default `true`. |

If the manifest is unreadable the tool errors instead of falling back to its own registry — a QA check that silently measured the wrong site's blocks would report an all-clear on a panel nobody has looked at.

See the [MCP server](/integration/mcp-server) page for connecting an MCP host.

## A check script for your repo

Put this in your own repository and run it against a running site. It fetches the manifest and the pages from the editor API routes you already mounted, fetches each page in editor mode to get the markers, and fails the build below a threshold.

```js theme={null}
// scripts/check-editing-surface.mjs
import {
  extractMarkedBlocks,
  editableCoverage,
  formatEditableCoverage,
  panelCoverage,
  formatPanelCoverage,
} from "@avocadostudio-ai/site-sdk/coverage"
import { getAllBlockMeta } from "@avocadostudio-ai/site-sdk/blocks"

const SITE = process.env.SITE_URL ?? "http://localhost:3000"
const SITE_ID = process.env.SITE_ID ?? "my-site"
const MIN = Number(process.env.MIN_COVERAGE ?? 100)

const json = async (url) => {
  const res = await fetch(url)
  if (!res.ok) throw new Error(`${url} → ${res.status}`)
  return res.json()
}

const manifest = await json(`${SITE}/api/editor/blocks`)
const { pages } = await json(`${SITE}/api/editor/pages`)

// 1. The preview half: fetch each page in editor mode and read its markers.
const marked = []
for (const page of pages) {
  // Slug-to-URL is your own convention; the sample content uses paths already,
  // like "/" and "/blog/building-with-blocks".
  const path = page.slug.startsWith("/") ? page.slug : `/${page.slug}`
  const url = new URL(path, SITE)
  url.searchParams.set("__editor", "1")
  url.searchParams.set("siteId", SITE_ID)
  // Required in production; unnecessary against a dev server.
  if (process.env.DRAFT_MODE_SECRET) {
    url.searchParams.set("secret", process.env.DRAFT_MODE_SECRET)
  }

  const html = await (await fetch(url)).text()
  for (const block of extractMarkedBlocks(html)) {
    // Supplying props is what keeps empty fields out of the denominator.
    block.props = page.blocks.find((b) => b.id === block.blockId)?.props
    marked.push(block)
  }
}

const editable = editableCoverage(manifest, marked)
console.log(formatEditableCoverage(editable))

// 2. The panel half: measure the manifest against the real content.
const panel = panelCoverage(manifest, pages, { builtinTypes: getAllBlockMeta() })
console.log("")
console.log(formatPanelCoverage(panel))

const pct = editable.expected === 0 ? 100 : (editable.marked / editable.expected) * 100
const blocking = panel.findings.filter((f) => f.code !== "phantom_field")

if (pct < MIN || blocking.length > 0) {
  console.error(`\nediting surface below threshold: ${pct.toFixed(0)}% marked, ` +
    `${blocking.length} panel findings (min ${MIN}%)`)
  process.exit(1)
}
```

Wire it up as a script:

```json theme={null}
{
  "scripts": {
    "check:editing-surface": "node scripts/check-editing-surface.mjs"
  }
}
```

<Warning>
  **The wrappers only appear in editor mode.** `getPreviewWrapperProps` returns `{}` when `editorMode` is false, so a page fetched without `__editor=1` (plus a `siteId`, and a valid `secret` in production) has no `data-block-id` on anything — and a field with no enclosing block is dropped by `extractMarkedBlocks`. A run against plain public HTML reports zero blocks and looks like a catastrophic regression.
</Warning>

<Tip>
  **Pick the threshold once and hold it at 100%.** Every rule in the section above exists so that a correct integration scores 100 and anything less is actionable. If you find yourself lowering the bar, the gap is usually a field that should be `panelOnly` or `internal` — say so in the declaration rather than in the threshold.
</Tip>

You can also run the whole thing hermetically, with no server: render each block with `renderToStaticMarkup`, wrap the output in the `data-block-id` / `data-block-type` wrapper a preview route would add, and feed that to `extractMarkedBlocks`. That is how Avocado measures its own block catalogue.

## On Astro

The same numbers, reached differently: there is no `renderToStaticMarkup` to
call, because your `.astro` components render on the server and Avocado never
renders a block itself.

Two ways to get editor-mode HTML to measure:

* **Ask a running site for one.** `astro dev` serves every route on demand, so
  fetch the page with `?__editor=1&siteId=…&session=…` (plus `secret=` in
  production) and feed the response to `extractMarkedBlocks`.
* **Build one.** A static site has no server to ask, so set
  `AVOCADO_FORCE_EDITOR=1` and the middleware marks every render — including
  prerendered ones — as the editor's. Read the markers out of the built HTML.
  The variable is read from the environment rather than taken as a config
  option, so a CI job can turn it on for one build without the committed config
  mentioning it, and nothing in a normal build can set it by accident.

`scripts/astro-check.mjs` in the Avocado repository does the first of those
against `examples/astro-site` and asserts 100%, alongside the draft gate, the
origin allowlist and the publish round trip. It is worth reading as a worked
example of the whole check.

## Getting coverage up

Every gap `editableCoverage` reports is closed the same way — by marking the element that draws the field. The helpers live in `@avocadostudio-ai/site-sdk/markers`, which holds the attribute helpers and nothing else. Import them from there rather than from `/editor`: most sites mark fields in components shared with their public pages, and reaching them through `/editor` pulls the overlay and the live-preview provider into the public bundle — measured at **+66 kB First Load JS on every page**, for markup that was byte-for-byte identical.

### Mark the wrapper, never the image

```tsx theme={null}
import { editableProps } from "@avocadostudio-ai/site-sdk/markers"

<div className="hero__media" {...editableProps("imageUrl", { kind: "image" })}>
  <Image src={props.imageUrl} alt={props.imageAlt} />
</div>
```

The overlay mounts its **Change** button by appending it *into* the marked element. An `<img>` cannot contain anything, so a marker on the image itself is inert: the attribute is in the HTML, the editor finds the field, and no button can ever appear. It looks instrumented from every angle except the one that matters — which is exactly what `markedOnVoidElement` is for.

Pass `kind` while you are there. Without it the overlay has to guess an image from the prop name against Avocado's own naming (`imageUrl`, `*.src`), so a site whose field is `photoUrl` gets a picker in the panel and no button in the preview, with no error on either side.

### Scope a row rendered by its own component

A field path is scoped from the block down — `items[3].question` — which a renderer can only write if it knows where it sits. That stops being true the moment a list row is drawn by a component of its own: the child knows it has a `question` and cannot know it is `items[3]`.

Forgetting the scope is silent and *wrong*, not silent and absent. The child marks a bare `question`, the overlay resolves it against the enclosing block, and editing a headline inside a column patches a prop the section does not have.

```tsx theme={null}
import { editableScopeProps } from "@avocadostudio-ai/site-sdk/markers"

{props.items.map((item, i) => (
  <li key={item.id} {...editableScopeProps(`items[${i}]`)}>
    <FaqRow item={item} />   {/* marks a bare "question"; needs no prefix */}
  </li>
))}
```

Scopes nest and compose outermost first, so a `left[1]` scope inside a `sections[0]` scope makes a child's `text` into `sections[0].left[1].text`. A block boundary ends the composition: a scope outside a block never reaches into it.

### When the row has no element to scope

A scope is a DOM attribute, so it needs an ancestor to sit on — and a list whose rows map straight into a flex or grid container has none to offer. Adding a plain wrapper makes *the wrapper* the flex item: gaps land in different places, `align-items` applies to the wrong box, and a grid's rows stop being the grid's children at all.

`display: contents` is the whole answer. The element stays in the tree for anything walking it and lays out as if it were not there:

```tsx theme={null}
<div className="flex flex-col gap-6">
  {props.items.map((item, i) => (
    <div key={item.id} {...editableScopeProps(`items[${i}]`, { display: "contents" })}>
      <FaqRow item={item} />
    </div>
  ))}
</div>
```

Leave the option off when the wrapper is one you were going to render anyway. A row that already has an `<li>` or a card `<div>` around it should carry the scope on that, not gain a second element to hold it.

<Note>
  Fields are found by walking `[data-editable-target]`; blocks are found by `closest("[data-block-id]")`. Mark fields without wrapping the block and you get a preview that renders, frames and scrolls correctly and clears the selection on every click — which looks exactly like [selection mode](/integration/nextjs-integration) being switched off.
</Note>

## When is the integration done?

Both checks clean, on every page you intend to edit:

<Steps>
  <Step title="editableCoverage at 100%">
    Every field that draws something carries a marker, and no image marker sits on a void element. Anything you deliberately exclude is excluded in the declaration — `panelOnly`, `internal`, `inlineEditable: false` — not in the threshold.
  </Step>

  <Step title="panelCoverage with no findings you have not decided about">
    Clear `colliding_type` first; it usually explains several of the findings below it. Then the rest. A finding you are leaving open should be written down with the reason, the same way a skipped test is.
  </Step>

  <Step title="Both wired into CI">
    Instrumentation is per-component work spread over a dozen files, which is why it gets done on one branch, not merged, and re-lost on the next. The symptom is always a single missing button, reported as a bug in the button. A check that runs on every branch is what stops that.
  </Step>
</Steps>

## Related

<CardGroup cols={2}>
  <Card title="Custom blocks" icon="cube" href="/integration/custom-blocks">
    Registering your own components, and declaring which of their props are content.
  </Card>

  <Card title="The field table" icon="table" href="/integration/field-table">
    One declaration, four consumers — including the `panelOnly` and `internal` flags these checks honour.
  </Card>

  <Card title="Next.js integration" icon="plug" href="/integration/nextjs-integration">
    The editor API routes the check script fetches from, and the preview wrapper it depends on.
  </Card>

  <Card title="MCP server" icon="robot" href="/integration/mcp-server">
    Connecting an MCP host so an agent can run `avocado-check-editing-surface` itself.
  </Card>
</CardGroup>
