BonnardBonnard

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 bare DashboardItem (a KPI tile, text block, or chart cell), renders one chrome-less cell. This is the usual case.
  • A DashboardSpec with itemId or item: n renders only that cell, chrome-less. Post the spec you already have and address the cell you want, rather than taking the spec apart yourself. Prefer itemId: an id is stable, and an array index moves when the dashboard changes. These are the same cell ids render_view selects with item_id, described in Rendering one cell.
  • A DashboardSpec with 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&notes=false
FlagValuesDefaultEffect
titledtrue, falsefalseDraw the widget's own title. Off by default, since you usually draw a header.
themelight, darkhostForce a theme. Otherwise the widget follows the host or OS preference.
notestrue, falsetrueDraw 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:

  1. You render a KPI. The widget reports content and 45px, you apply it, and the frame shrinks to 45px.
  2. You render a chart into the same frame. It is fill-sized, so it reports no height.
  3. 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:

  1. The most recent valid theme on a bonnard:render message. This is a persistent explicit override: once set, a later host or OS change will not revert it.
  2. The theme flag on the #embed fragment, which is the initial theme.
  3. 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 needs allow-same-origin, allow-popups, allow-forms, or allow-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-Policy that denies every fetching directive as defence in depth. Token validation is what keeps a caller-supplied value from becoming a url() 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.source is 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 no allow-same-origin, and this stops any other window holding the frame's WindowProxy from replacing its content or applying hostile tokens.
  • Check event.source on your side too. Compare it against your iframe's contentWindow before you trust a message, as the quickstart does.
  • Payloads are bounded by EMBED_LIMITS, so one malformed or oversized message is refused with bonnard:error rather 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 DashboardSpec and 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.

On this page