Skip to main content
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.

editableCoverage

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.

panelCoverage

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.

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. 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.
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.
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:
formatEditableCoverage turns that into something you read in a terminal:
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.
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:
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.
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.
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.
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.
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.
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.
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.
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.
Here is what a real run looks like with six of the seven present:

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

Three ways to run them

Your own check script

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

The MCP tool

avocado-check-editing-surface, for an agent to call before it reports an integration done.

The formatted report

formatEditableCoverage and formatPanelCoverage, for a person to read in a terminal or a CI log.
Everything is exported from one subpath:
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. 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 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.
Wire it up as a script:
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.
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.
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

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.
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:
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.
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 being switched off.

When is the integration done?

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

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

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

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.

Custom blocks

Registering your own components, and declaring which of their props are content.

The field table

One declaration, four consumers — including the panelOnly and internal flags these checks honour.

Next.js integration

The editor API routes the check script fetches from, and the preview wrapper it depends on.

MCP server

Connecting an MCP host so an agent can run avocado-check-editing-surface itself.