Surfaces
A surface is one output format. Every surface takes the same Composition and exposes the same two methods — render for a whole composition, renderNode for a single node — so a host picks a surface by the channel it is answering into, not by what the composition contains.
runtime.surfaces.text.render(composition);
runtime.surfaces.slack.render(composition, { collectAssets: true });
| Surface | render returns |
Validates | Options |
|---|---|---|---|
react |
ReactNode |
no | context (required only if the pack narrows it), heading, wrapper |
html |
HTMLRenderResult |
yes | theme, fluid, framed, heading, css, scripts, minify, enhancements (ids), anchors, onValidationError |
text |
string |
yes | heading, onValidationError |
markdown |
string |
yes | heading, onValidationError |
slack |
{ text, blocks, assets } |
yes | heading, text, collectAssets, assetPrefix, onValidationError |
svg |
SvgRenderResult |
yes | frame, width, height, theme, onValidationError |
HTMLRenderResult is { html, css, js, body, measurement, validationErrors }. Embedding covers getting that markup, stylesheet, and script onto a page a host already controls, including when to render with scripts: 'host'. The svg entry is undefined unless the runtime was given frames, and the factory's return type tracks which.
All six are synchronous.
heading defaults to true wherever it appears: the composition's title and subtitle open the output, as an h2 and p.sub on react and html, an h1 and italic line in Markdown, an uppercased line in text, and a header and context block in Slack. Pass false when the host already shows the title, or the body opens with its own heading, as a slide does. Slack then leaves them out of its fallback text too, and html still uses the title for the wrapper's aria-label.
Whether a surface validates is a declared field on its type — readonly validating: true or false — so a host can reason about it without reading implementations.
The HTML surface reports: the render proceeds and the findings come back as validationErrors on the result, because a partial document is still worth showing. The other four validating surfaces throw CompositionValidationError by default, because a string, a Slack payload, or an image has no place to carry findings and would otherwise go out as if it were sound. onValidationError flips either posture: 'throw' on HTML, 'collect' on the rest to validate and render anyway.
React is the exception, deliberately. It is the interactive target, where a partial render beats a thrown error and a host would rather show the parts of a composition that are well-formed. surfaces.react.render will render a composition that runtime.validate rejects. A host that wants the other behaviour validates first.
The React surface emits an h2 and a p.sub for the composition's title and subtitle, then the body nodes, inside a dispatcher context provider. The wrapper element, the aria-label, the data-theme attribute, and the stylesheet belong to the host — by default. Pass wrapper and render/renderNode wrap the content in the same .isomer[.framed][.fluid] section the html surface emits, with the same aria-label fallback and data-theme, instead of every React-only host reimplementing those class names:
runtime.surfaces.react.render(composition, { wrapper: true });
runtime.surfaces.react.render(composition, {
context: { onEvent },
wrapper: { fluid: true, theme: 'dark' },
});
Omit wrapper and the surface returns bare content.
renderNode takes ReactRenderNodeOptions, which is ReactRenderOptions without heading: a lone node has no composition title to draw. heading still controls it on render. Slack's SlackRenderNodeOptions omits heading for the same reason.
The options argument, and context inside it, is optional only when omitting it is sound. The SDK's own render context has no required field, so {} is a complete value and both may be left off; a pack that narrows the context with something mandatory makes them required for that binding rather than letting the surface fabricate a value missing fields its renderers will read.
context is typed by the runtime's TRenderContext, which is inferred from styleAdapter alone. A host that supplies no adapter and loads a pack that narrows its context, such as the slides pack's SlideRenderContext, gets the SDK's PrimitiveRenderContext by inference, and a narrowed field in context is then an excess property. Name all three type parameters positionally to type it:
const runtime = createIsomerRuntime<unknown, SlideRenderContext, SlideFrameTheme>({
packs: [slidesPack],
frames: { slide: slideDeckFrame },
});
runtime.surfaces.react.render(composition, { context: { resolveClassName } });
The first parameter is the host context views build with, the second the render context, the third the theme; Runtime covers when each is worth naming.
The React surface imports @elastic/isomer-sdk/react, not ./html, so that module does not load react-dom/server. The package entry still does: createIsomerRuntime always constructs both react and html, so react and react-dom are required peers of this package.
slack.renderNode returns the same { text, blocks, assets } as render, fitted to Slack's limits, and takes the same options minus onValidationError. A picture node degrades to markdown unless collectAssets is set, exactly as it does in a composition, so a host that can upload passes the option on both paths:
const { blocks, assets } = runtime.surfaces.slack.renderNode(chart, {
collectAssets: true,
});
The svg surface returns { element, css, width, height }, not SVG bytes. Rasterization is a separate capability a host opts into, and stopping at that boundary is what keeps this package isomorphic.
Both halves are needed together. element is the same React tree the DOM gets, carrying class names; css is the packs' stylesheet, which an image backend is handed the way a browser is handed a <style>. Its light-dark(…) values are already resolved to the render's scheme, because an image is one static frame with no color scheme to resolve them against.
const svg = runtime.surfaces.svg;
const { width, height } = svg.resolveViewport(composition);
const { element, css } = svg.render(composition, { theme: 'light' });
renderNode on this surface takes only frame and theme. Geometry is absent deliberately: a node drawn with no surround has nothing for a width or height to size.
validate returns warnings alongside errors, and each warning names the surface it applies to. A missing metrics.svgHeight matters to an image host and is noise to a terminal one, so narrow before showing:
import { warningsForSurface } from '@elastic/isomer-sdk';
const result = runtime.validate(composition);
const relevant = warningsForSurface(result, 'svg');
HTML rendering emits one <style> per document. A pack ships its own styleAdapter and the runtime combines them, routing each style handle to its owner, so a host loading styled packs supplies nothing. A pack is also CSS-bearing when any primitive declares collectStyles. Construction throws if such a pack has no adapter at all, naming both the pack and the primitive.
A host composing a CSS-bearing pack to render only text or Slack still has to answer for that CSS. The escape hatch is a no-op adapter — explicit rather than accidental:
const runtime = createIsomerRuntime({
packs: [cssBearingPack],
styleAdapter: {
createCollector: () => ({}),
createRenderContext: () => ({}),
renderStyles: () => '',
},
});
A host adapter replaces every pack's, so the no-op above silences all of them rather than one.
Two packs that each declare a styleAdapter need ownsHandle(handle) on both, so the runtime can route each style handle to the adapter that made it rather than guessing:
const runtime = createIsomerRuntime({
packs: [chartsPack, calloutsPack],
});
- both declare styleAdapter
If calloutsPack's adapter omits ownsHandle, construction throws AMBIGUOUS_STYLE_ADAPTER: with two adapters and no way to ask one of them "is this handle yours?", a handle it did not create could be routed to it by elimination and rendered against the wrong theme. The fix is either adapter-side — calloutsPack adds ownsHandle — or host-side: pass a styleAdapter on createIsomerRuntime to replace both packs' own, which is also how the no-op above silences a single CSS-bearing pack.
styleCollector is a second, independent check: if calloutsPack declares one (naming the collector shape its collectStyles hooks expect) and the adapter actually serving it — its own, or chartsPack's when calloutsPack declares none — creates a differently-tagged collector, construction throws INCOMPATIBLE_STYLE_COLLECTOR instead of corrupting the collector silently at render time. The fix is a styleAdapter both packs were written against — a host adapter, or one pack adopting the other's.
| The channel is… | Call |
|---|---|
| A React app | react, plus your own wrapper and CSS |
| Email, a report, a static page | html (inline the CSS for mail clients) |
| Slack | slack, then upload assets |
| An agent transcript or chat reply | markdown |
| A terminal, SMS, any low-capability host | text |
| An image | svg, then a rasterizer |
getCapabilities().formats reports what this runtime can actually produce, which is the programmatic version of that table.