Embed mode
Render one chart, KPI, or table with no chrome of its own inside a layout you already own, driven over a postMessage protocol.
Embed mode renders one chart, KPI tile, or table with no chrome of its own, so you can place it in a
layout you already own. You turn it on with a #embed fragment on the widget URL and drive it with
postMessage from the parent page.
Outside embed mode the widget draws a whole host surface: a grid, cell borders, fixed chart heights, and its own titles. That is right for an MCP host, where the widget owns the viewport. In your own admin console the widget is one tile among many, and all of that becomes something to crop around. Embed mode is opt-in, so the MCP path is unchanged and a server that never sets the fragment behaves exactly as it did before.
Quickstart
Serve the widget from your own route. WIDGET_HTML is the same self-contained file the
ui://bonnard/chart resource serves over MCP.
import { WIDGET_HTML } from "@bonnard/mcp-charts";
app.get("/chart-widget", (_req, res) => res.type("html").send(WIDGET_HTML));The protocol types ship with the package, so you do not have to copy them out of this page:
import type {
BonnardRenderMessage,
BonnardWidgetMessage,
BonnardErrorCode,
EmbedTokens,
EmbedPayload,
} from "@bonnard/mcp-charts";
import { EMBED_PROTOCOL_VERSION, EMBED_LIMITS } from "@bonnard/mcp-charts";Then embed one cell. The container decides the size, and sandbox="allow-scripts" is all the widget
needs:
<style>
.my-card {
height: 260px;
}
.my-card iframe {
width: 100%;
height: 100%;
border: 0;
display: block;
}
</style>
<div class="my-card">
<iframe id="rev" src="/chart-widget#embed" sandbox="allow-scripts"></iframe>
</div>
<script>
const frame = document.getElementById("rev");
const spec = /* a ChartSpec, DashboardSpec, or DashboardItem */;
window.addEventListener("message", (e) => {
// The frame is opaque-origin, so event.source is the only identity you have. Always check it.
if (e.source !== frame.contentWindow) return;
if (e.data?.type === "bonnard:ready") {
frame.contentWindow.postMessage({ type: "bonnard:render", payload: spec, theme: "light" }, "*");
}
if (e.data?.type === "bonnard:size") {
// "content" carries a height to apply; "fill" means release the height you applied.
if (e.data.sizing === "content") frame.style.height = e.data.height + "px";
else frame.style.removeProperty("height");
}
if (e.data?.type === "bonnard:error") {
console.warn("chart render refused:", e.data.code, e.data.message);
}
});
</script>The container owns the height and the frame fills it through a stylesheet rule, which matters for sizing: releasing an applied inline height then falls back to the container rather than to the iframe default.
Wait for bonnard:ready before you post. The widget emits it on every load, so a frame that reloads
(moving an iframe in the DOM reloads it) asks you for the payload again instead of going blank.
What you can render
The payload field accepts three shapes:
- A
ChartSpec, or a bareDashboardItem(a KPI tile, text block, or chart cell), renders one chrome-less cell. This is the usual case. - A
DashboardSpecwithitemIdoritem: nrenders only that cell, chrome-less. Post the spec you already have and address the cell you want, rather than taking the spec apart yourself. PreferitemId: an id is stable, and an array index moves when the dashboard changes. These are the same cell idsrender_viewselects withitem_id, described in Rendering one cell. - A
DashboardSpecwith no selector renders the grid inside your container, with the outer padding and the dashboard title dropped.
Selection fails closed. A negative, non-integer, wrong-typed, or out-of-range selector, or an
itemId that matches nothing, returns bonnard:error and draws nothing. It does not fall back to
the whole grid, which would spill other cells into your layout.
Fragment flags
Flags are static per instance, so they belong on the URL rather than in every message. Everything
after #embed parses as a query string.
/chart-widget#embed
/chart-widget#embed&titled=true&theme=dark¬es=false| Flag | Values | Default | Effect |
|---|---|---|---|
titled | true, false | false | Draw the widget's own title. Off by default, since you usually draw a header. |
theme | light, dark | host | Force a theme. Otherwise the widget follows the host or OS preference. |
notes | true, false | true | Draw guardrail advisories ("Showing the top 30 of 1000..."). |
A bare flag reads as on, so #embed&titled is titled=true. Unknown flags and unknown values are
ignored, which keeps a newer consumer URL safe against an older widget.
Leave notes on unless you surface the advisories yourself. They carry the reasons a chart looks the
way it does: coerced columns, capped categories, an empty result. Hiding them hides a data problem.
Messages
Parent to widget
{
type: "bonnard:render",
payload: ChartSpec | DashboardSpec | DashboardItem,
itemId?: string, // with a DashboardSpec payload: render only the item with this id
item?: number, // the same, by array index
theme?: "light" | "dark",
tokens?: EmbedTokens,
renderId?: string, // echoed back on bonnard:error
}Widget to parent
{ type: "bonnard:ready", protocolVersion: 1 }
{ type: "bonnard:size", sizing: "content", height: number, width: number }
{ type: "bonnard:size", sizing: "fill", height: null, width: number }
{ type: "bonnard:error", code: BonnardErrorCode, message: string, renderId?: string }bonnard:ready fires on every load, in the same turn as the first paint. Embed mode runs no
handshake and waits on nothing, so it never depends on your page answering something first.
protocolVersion is 1 today. Check it if you need to detect a consumer running an older installed
copy of the package.
bonnard:error means the render was refused and nothing was drawn, so whatever was on screen before
stays. code is one of invalid-payload, payload-too-large, item-not-found,
invalid-item-selector, or render-failed. Set renderId on your render message to correlate the
two.
Payloads are validated against the bounds exported as EMBED_LIMITS: rows, items, series and
columns, string lengths, notes, and nesting depth. A payload past any bound is refused whole rather
than truncated.
Sizing
Charts and intrinsic cells size differently, so every bonnard:size message carries a sizing
discriminant, and you branch on it:
if (e.data?.type === "bonnard:size") {
if (e.data.sizing === "content") frame.style.height = e.data.height + "px";
else frame.style.removeProperty("height"); // sizing: "fill"
}KPI, text, and table cells are content-height. They report sizing: "content" with a measured
height. Apply it to the frame.
Charts fill their container. A chart has no intrinsic height, so only you can decide it: give the
container a height and the chart takes all of it at 1:1, with no scaling. A chart reports
sizing: "fill" with height: null, which means release any height you previously applied to this
frame and fall back to your own layout height. It never reports a measured height, so there is
nothing to feed back.
Give the frame its height through a stylesheet rule on a sized container (height: 100% of a 260px
card) rather than an inline style. Releasing the inline height the widget asked you to apply then
returns the frame to the container's height instead of collapsing to the iframe default.
Why the release message exists
Without it, this sequence strands a frame at the wrong height:
- You render a KPI. The widget reports
contentand 45px, you apply it, and the frame shrinks to 45px. - You render a chart into the same frame. It is fill-sized, so it reports no height.
- The frame is still 45px, the chart fills 45px, and nothing ever tells you to let go.
The sizing: "fill" message closes that gap. The widget also stays silent until a payload has
actually rendered. The "waiting for chart data" placeholder is content-shaped, but its height says
nothing about the payload that is coming, so reporting it would shrink your frame before the first
chart arrived.
Reports fire after the first paint, after every re-render, and whenever a ResizeObserver on the
content sees a change such as fonts loading or text rewrapping. They are coalesced to one message per
animation frame and suppressed when the measurement has not changed, so a stable cell goes quiet.
Nothing here can loop: in content mode the measurement depends on the content and never on the height
you just wrote back, and fill charts report no height at all. The measurement happens inside the
frame, so none of it needs allow-same-origin.
One caveat when testing. Chrome throttles requestAnimationFrame for offscreen cross-origin iframes,
and the reporter is coalesced on animation frames, so a frame scrolled out of view may report nothing
until it is visible. That is browser throttling rather than the widget going quiet.
Theme tokens
theme: "light" | "dark" drives both the widget CSS and the chart palette. For anything past that,
pass a bounded set of tokens to match your own design system:
interface EmbedTokens {
bg?: string; // page background
fg?: string; // body text
muted?: string; // labels, captions, notes
border?: string; // table rules, cell borders
fontFamily?: string; // the HTML surface only, not chart text
}frame.contentWindow.postMessage(
{
type: "bonnard:render",
payload: spec,
theme: "light",
tokens: { bg: "#fffdf7", fg: "#1c1917", muted: "#78716c", fontFamily: "Inter, system-ui, sans-serif" },
},
"*",
);Tokens theme the HTML surface: the page background, body text, table rules and headers, KPI
tiles, text blocks, and notes. They do not theme chart internals. Axes, gridlines, legend, tooltip,
series palette, and chart text follow theme: "light" | "dark" and are otherwise fixed. If you need
branded chart colours, embed mode cannot do that yet.
Tokens are set as CSS custom properties, never injected as CSS text, and each one is validated
against the grammar for its own property rather than against a list of banned substrings. Colour
tokens (bg, fg, muted, border) must be a hex colour, one of a short list of CSS colour
keywords, or a numeric colour function such as rgb() or oklch(). fontFamily must be a
comma-separated list of quoted strings or bare identifiers. Everything else is dropped, including
url() in any spelling, gradients, var(), attr(), comments, backslash escapes, and any value
over 120 characters. Invalid tokens are dropped silently and one at a time, so the rest of the object
still applies. Omitting a token you previously set clears it back to the theme default.
Theme precedence
Highest wins:
- The most recent valid
themeon abonnard:rendermessage. This is a persistent explicit override: once set, a later host or OS change will not revert it. - The
themeflag on the#embedfragment, which is the initial theme. - The host or OS preference, used only when neither of the above is present.
Security
Embed mode ships the same posture as the MCP path and does not relax it.
sandbox="allow-scripts"is sufficient. Nothing in embed mode needsallow-same-origin,allow-popups,allow-forms, orallow-top-navigation. Do not add them.- The widget makes no network requests of its own. It is one self-contained file with the chart
library inlined, and it ships a restrictive
Content-Security-Policythat denies every fetching directive as defence in depth. Token validation is what keeps a caller-supplied value from becoming aurl()and turning into a request, and validating by property grammar rather than by denylist is why escaped spellings cannot get through. - Every payload string is escaped before it reaches the DOM. Specs are agent-generated and tenant-derived, so a renderer bug stays inside the sandbox. That containment is why the widget is an iframe rather than a web component.
- The widget authenticates you. It only accepts messages whose
event.sourceis its own parent window. It cannot check your origin from an opaque frame, since there is no useful origin string to compare, but sender identity needs noallow-same-origin, and this stops any other window holding the frame'sWindowProxyfrom replacing its content or applying hostile tokens. - Check
event.sourceon your side too. Compare it against your iframe'scontentWindowbefore you trust a message, as the quickstart does. - Payloads are bounded by
EMBED_LIMITS, so one malformed or oversized message is refused withbonnard:errorrather than stalling the frame.
Stability
The #embed fragment and its flags, the four message types and their documented fields, the sizing
discriminant, selection failing closed, the EmbedTokens keys, and the exported protocol types are
public and semver-governed. Internal CSS class names, DOM structure, pixel constants, and the exact
values in EMBED_LIMITS are not.
Within a major line, flags and message fields are added, never removed or re-typed, and unknown flags
and fields are ignored on both sides, so a new consumer against an old widget degrades rather than
breaks. You serve WIDGET_HTML from your own installed copy of the package, so an existing embed
changes only when you upgrade.
Runnable example
examples/embed is a two-card
page: a fill-sized chart and a content-sized cell that resizes itself from bonnard:size, with
pickers for the payload kind, theme, and tokens. One of the token sets is deliberately invalid, so
you can watch validation drop it.
Next
- Dashboards: the
DashboardSpecand the cell shapes you post as a payload. - Chart Types: the eight types and the data shape each one expects.
- Preview your charts locally: render a spec in the same widget from your terminal before you wire up an embed.