Frame
A frame is a document: the picture-frame a snapshot render sits inside, the geometry that sizes it, the palette it draws with, and whatever it requires of a body it is asked to draw. The SDK owns the type; a theme package or a pack supplies instances; a host passes them to the runtime and names one per render.
Frame is the picture-frame sense, not browser chrome. The hook that surrounds an already-rendered body is wrap, not render.
interface Frame<TTheme> {
defaultWidth: number;
theme: { light: TTheme; dark: TTheme };
sizing?: 'fixed' | 'content';
estimateHeight(composition, dispatcher): number;
validateBody?(body): readonly string[];
wrap: (header, body, viewport) => ReactNode;
}
- FrameSizing, default 'fixed'
A frame is named by the key a host registers it under, not by a field it carries.
body here is FrameBody (readonly ReactNode[]), the already-rendered output, distinct from Composition.body (raw PrimitiveNode[]).
A vocabulary (which primitives exist — additive, any number) and a document (what gets produced — exclusive, one per render) are independent. Splitting them makes a pack purely additive. Nothing about a primitive decides the frame, so any pack's nodes can be drawn inside any frame.
Frame.wrap draws only the surround. BoundFrame.render walks the composition's body through the runtime-wide dispatcher first, one FrameDispatcher.renderSnapshotNode(node, key) per node — keyed on node type, never needing to know which pack a node came from — and hands Frame.wrap the already-rendered result.
What the frame is told is deliberately narrow:
interface FrameHeader {
title?: string;
subtitle?: string;
theme?: RenderTheme;
}
The body is withheld from wrap, not merely undeclared. The runtime names these three fields into a fresh value per render, because narrowing the type alone would leave body reachable and spreading the composition would carry through whatever else the caller's value holds. A wrap that could read the nodes could branch on a pack's node types when it draws the surround. estimateHeight and validateBody do receive the nodes, since a document's size and its rules about a body depend on them.
wrap takes the rendered body as an array rather than one ReactNode, so a frame that draws no surround can return its single root element — image layout starts from one element, and a fragment is not one.
interface FrameViewport<TTheme> {
width: number;
height: number;
theme: TTheme;
mode: RenderTheme | undefined;
}
- resolved from the pair
- what the caller asked for
An image has no media queries, so it must commit to one palette; bindFrame resolves light/dark per render and auto resolves light. The type is named for the viewport rather than the frame because wrap draws a frame within it.
BoundFrame.resolveTheme(mode) resolves the same palette, erased to unknown. The snapshot surface calls it once per render and hands the result to renderSnapshot as theme, which reaches a primitive's react renderer as env.theme — see Dispatch, and Light and dark for drawing with it.
bindFrame resolves a frame's palette per render and returns a BoundFrame — defaultWidth, sizing, estimateHeight, validateBody, render, and renderNode — with no TTheme left in any signature. The erasure is a closure rather than a type parameter because the runtime dispatches every node through one inventory keyed by node type, and that inventory has no single theme to name.
What makes the erasure safe is that a runtime is homogeneous in its theme. A pack declares what its snapshot renderers read through a phantom marker on PrimitivePack<TTheme> — so the palette is checked where the real type is still known, and only the resolved result is stored.
What this document requires of a body — the slide document's exactly-one-slideFrame rule is the example. It returns messages rather than throwing, and note where it is not consulted: runtime.validate is frame-agnostic and has to be, because the same composition can be valid in one frame and not another. Only the snapshot surface checks it, on the frame a render actually names.
fixed, the default, makes estimateHeight the document's height. content makes it an estimate: the snapshot surface marks the result heightMode: 'content', and an image backend sizes the output to the root wrap returns, laid out at the width. A content frame therefore should not pin its root to FrameViewport.height. BoundFrame.sizing is the resolved value. See the runtime's Frame page for when to still declare metrics.snapshotHeight.
estimateSnapshotHeight returns 0 for a node whose primitive declares no metrics.snapshotHeight, so a fixed frame whose estimateHeight calls it sizes short for that node; a content frame's estimate sizes nothing, and the surface reports no gap for it. A frame that returns a constant never reads the metric. validate reports nothing about the gap: the frame is chosen per render, and one composition can be drawn into a measuring frame and a fixed one. The runtime's snapshot surface reports it on the result of the render that measured the nodes.
Dispatch for renderSnapshot and estimateSnapshotHeight