Primitives
A primitive is one entry in a pack's closed vocabulary: a node type, the schema that validates it, the copy that describes it to an agent, and one renderer per surface. definePrimitive is where all of that is stated together, and it is the unit the dispatcher, the validator, and the JSON Schema projection are all keyed on.
The word names the definition, never the instance. An instance in a composition is a node.
import { type PrimitiveNode, definePrimitive, requiredString, z } from '@elastic/isomer-sdk';
import { md } from '@elastic/isomer-sdk/markdown';
import { type SlackBlock } from '@elastic/isomer-sdk/slack';
export const kpiSchema = z.object({
type: z.literal('kpi'),
label: requiredString(),
value: requiredString(),
delta: z.string().optional(),
});
export type KpiNode = z.infer<typeof kpiSchema> & PrimitiveNode;
const examples: KpiNode[] = [{ type: 'kpi', label: 'Error rate', value: '0.4%', delta: '-0.2pp' }];
export const kpi = definePrimitive({
type: 'kpi',
schema: kpiSchema,
catalog: {
type: 'kpi',
purpose: 'State one measured value with an optional change.',
useWhen: ['A single number answers the question.'],
avoidWhen: ['Three or more values belong together — use a stat group.'],
example: { type: 'kpi', label: 'Error rate', value: '0.4%' },
},
examples,
renderers: {
react: (node) => <p>{`${node.label}: ${node.value}`}</p>,
text: (node) => `${node.label}: ${node.value}${node.delta ? ` (${node.delta})` : ''}`,
markdown: (node) =>
md.paragraph(
md.strong(node.label),
`: ${node.value}`,
...(node.delta ? [' ', md.emphasis(`(${node.delta})`)] : [])
),
slack: (node): SlackBlock => ({
type: 'section',
text: { type: 'mrkdwn', text: `*${node.label}*\n${node.value}` },
}),
},
});
The schema is the declaration: the node type is z.infer of it, the typed examples array fixes TNode, and definePrimitive's type arguments stay inferred. The quick start puts this primitive in a pack and renders it.
| Field | Required | What it is |
|---|---|---|
type |
yes | The discriminator. Unique across every pack in a runtime. |
schema |
yes | A Zod object schema for this node on its own. |
catalog |
yes | purpose, useWhen, avoidWhen, example; optional name, description. |
icon |
no | A host-facing 16×16 SVG glyph; see Names and icons. |
examples |
yes | Conformance nodes, bare or named; prompt shows catalog.example. |
renderers |
yes | react, text, markdown always; slack optionally. |
schemaFor |
container | The same schema, bound to the composition's body-node union. |
sanitize |
no | Last-chance repair or rejection, run before every render. |
children |
container | Nested nodes, each with the field path it lives under. |
hasOwnContent |
hybrid | Whether the parent still renders when every child is hidden. |
metrics |
no | snapshotHeight, so a measuring frame can size itself. |
collectStyles |
no | Contributes CSS through the pack's style adapter. |
An entry of examples is a node, or a PrimitiveExample that names one so a host listing examples, such as a Storybook or a docs page, can label it:
examples: [
example,
{ name: 'Warning tone', description: 'A rising error rate.', node: warningExample },
],
A name is non-empty and unique within its primitive, which inventory conformance checks, and a failing conformance case prints it beside the index. Read examples through exampleNodes(definition) for the nodes alone or primitiveExamples(definition) for { name?, description?, node } each, rather than definition.examples directly; both keep every node type across a union of definitions.
definePrimitive extends the schema with two optional fields, id so any node can be addressed and surfaces so an author can hide a node from a surface, and closes it to unknown keys, so a hallucinated field is a validation error rather than a silent strip. A schema that declares id or surfaces itself throws an IsomerError with code RESERVED_NODE_FIELD. A schema built with z.looseObject keeps its catchall. The extension keeps the concrete schema type, including brands. A container's schemaFor gets the same treatment, or a node would be legal standalone and rejected inside a composition.
react(node, { context, scope }): ReactNode
text(node, { scope }): string
markdown(node, { scope }): MarkdownContent
slack?(node, { collector, scope }): unknown | readonly unknown[]
- always
- always
- always
There is no snapshot renderer. The snapshot surface dispatches to react: an image backend lays out the pack's DOM tree and stylesheet, so a second tree authored for the image would be the same layout stated twice. Targeting images therefore costs a primitive nothing.
A markdown renderer returns content built with md from ./markdown. md escapes each value where it lands, so authored *, |, or a leading # stays text, and its links and images apply the URL policy. Markdown a pack already has, such as authored source, goes through md.authored, which sanitizes it and drops it when it is only whitespace. md.blockquote quotes blocks, embedded child content included, md.break() breaks a paragraph's line with a backslash and prints as a space inside a heading, strong, emphasis, link label, or table cell, and one at the start or end of any of these is dropped, and a md.table cell takes one inline run or an array of them.
slack is the one optional renderer, and it degrades: with none the dispatcher converts the mandatory markdown to Block Kit, so a missing renderer costs fidelity and nothing else. A container that titles its body renders through slackContainer (Titled sections on Slack). A block Isomer does not model yet goes through unmodeledSlackBlock (Slack blocks Isomer does not model yet).
The second argument is a bag so a new extra on a surface is additive: an existing renderer destructures a subset and keeps compiling. scope is a RenderScope, the dispatcher erased to PrimitiveNode so a container can recurse into a child owned by another pack. Leaf renderers can omit the bag.
A container holds other nodes, and it cannot know which types are legal, since that depends on the runtime it lands in. So it declares the shape and lets the composition supply the union:
schema: z.object({ type: z.literal('wrap'), items: z.array(unresolvedBodyNodeSchema) }),
schemaFor: (bodyNode) => z.object({ type: z.literal('wrap'), items: z.array(bodyNode) }),
unresolvedBodyNodeSchema is the standalone slot: permissive but not z.unknown(), so a child that is not a node at all still fails without an inventory. Everything that depends on which types are legal goes through schemaFor. A primitive that declares schemaFor is the one case where the node type stays a hand-written interface, because the body-node union is injected per composition and z.infer cannot name that cycle.
children returns each nested node with the field path it lives under:
children: (node) =>
node.items.map((item, index) => ({
node: item.node,
path: `items[${index}].node`,
}));
Returning bare nodes would leave every caller guessing the field name, and error paths would name a field that does not resolve. hasOwnContent exists for the hybrid case: a container that also owns string fields keeps rendering when every nested node is hidden from a surface, which a walker looking only at children would report as rendering nothing.
Return them in the order the react renderer draws them. Node anchors pair nodes with elements by that order, so a container that draws its children in a different order pairs them with the wrong elements.
Every renderer recurses through scope, never a switch over the pack's own types. That is what lets a node from a second pack nest inside this one's container:
renderers: {
react: (node, { context, scope }) => (
<>{node.items.map((item, i) => <Fragment key={i}>{scope.renderReact(item.node, context)}</Fragment>)}</>
),
text: (node, { scope }) => node.items.map((item) => scope.renderText(item.node)).join('\n'),
markdown: (node, { scope }) => node.items.map((item) => scope.renderMarkdownContent(item.node)),
},
A container that instead pattern-matches item.node.type against its own pack's types renders correctly until a host nests a node from a different pack, then throws UNKNOWN_PRIMITIVE_TYPE. scope is erased to PrimitiveNode precisely so that mistake cannot compile. ./testing's conformance harness has a case for this: implement PrimitiveConformanceHarness.nestForeignChild and it runs automatically.
Runs before every render, on every surface, and may return null to drop the node. It is the render-time half of the URL trust policy: the Zod refinement gives an agent a useful validation error, and sanitize means a renderer invoked with an unvalidated node still never emits an unsafe URL.
Optional. The height estimate is 0 when it is missing, so a frame that sums node heights sizes short. The runtime's snapshot surface reports each such node when the frame that render uses calls estimateSnapshotHeight. A frame that returns a constant reads nothing from it, and validate reports nothing about it.
A host listing primitives, in a nav, a docs gallery, or an example picker, shows a name and a glyph beside each one. Neither reaches the model.
catalog.name is the name, such as Stat group. It is the one host-facing field in catalog: the authoring prompt never prints it, so a model is not invited to write it as a type. Absent, the host derives a name from type. Inventory conformance rejects a blank one. catalog.description is the $def description in the authoring JSON Schema.
icon is { svg }, a static string a host inlines as markup without a renderer, and pack.icons maps each type to it. Absent, the host supplies its own glyph, such as a monogram. Because a host inserts the string as markup, its rules are an allowlist, which assertPackIconsValid from ./testing enforces:
- One
<svg>root withviewBox="0 0 16 16", nowidthorheight, and an optionalxmlns. - Elements:
svg,g,path,rect,circle,ellipse,line,polyline,polygon. No text, script, style, image,use, orforeignObject. - Attributes: geometry (
d,x,y,cx,cy,r,rx,ry,x1…y2,points,widthandheightonrect),transform, and paint (fill,stroke,stroke-width,stroke-linecap,stroke-linejoin,opacity,fill-opacity,stroke-opacity,fill-rule,clip-rule). Nostyle,class,href, or event handlers. Values are quoted and hold no entities. - Every
fillandstrokeisnone,currentColor, or one of the fourICON_VARS, optionally with acurrentColoror#rgb/#rrggbbfallback:var(--isomer-icon-accent, #3d5ad8). - No comments, declarations, CDATA, text, or anything after
</svg>. - Separators are HTML whitespace (space, tab, line feed, form feed, carriage return), never NBSP or another Unicode space, which an HTML parser reads as part of a name or as text.
The four slots are accent for the one emphasised element, muted for bodies, fg for lines, and bg for a tile behind the glyph, if the icon draws one.
A fallback is the author's choice. A hex fallback is the icon's default look when the host sets nothing; it is one look for every mode. A currentColor fallback inherits the host's text colour instead. A host restyles icons by setting the variables, across the page or scoped to one type, as Kibana does for its illustrations (kibana#221938): one SVG, with the colours supplied per theme and mode.
An icon is exempt from the rule that a pack holds no rendered value of its own: its geometry and fallbacks are host-facing defaults the pack never draws.
Every contract here is generic in one bag, PackTypes: the palette a frame supplies (theme), the context react renderers receive (context), the collector collectStyles mutates (collector), and the Slack payload and collector types. DefaultPackTypes is the sdk's own binding, PrimitiveRenderContext and everything else unknown, and the default everywhere. A pack narrows what it needs once and binds definePrimitive to it:
interface SlidesPackTypes extends DefaultPackTypes {
theme: SlideTheme;
context: SlideRenderContext;
slackBlock: SlackBlock;
}
export const definePrimitive = definePrimitiveFor<SlidesPackTypes>();
Each primitive then names only TNode, and createPrimitiveDispatcher<TNode, SlidesPackTypes> dispatches with the same types.
collectStyles is the same shape: (node, { styles, context }). The dispatcher's collectStyles(node, styles, context) stays positional.
PrimitiveRenderContext is enhancements, anchors, and onEvent. A renderer spreads nodeAnchor(context, node) on its root element so runtime code can find it; see Node anchors. CSS-in-JS lives on StyledRenderContext (resolveClassName, cssVarRef), which the HTML style adapter produces. A pack that styles differently declares its own TContext.
Type arguments stay inferred. TypeScript has no partial inference, so naming TNode explicitly (definePrimitive<KpiNode>({ … })) resets TSchema to its default, and buildJsxShim then types that primitive's props loosely. Type the examples array with the schema-derived node type and let inference do the rest. Pass TNode explicitly only for a schemaFor container, whose node type is hand-written anyway.
The schema must be a ZodObject, not a union or an intersection. The composition builds a z.discriminatedUnion('type', …) over every primitive, and typing the field as ZodObject turns "this cannot be a union" into a compile error at the definition rather than a runtime failure at the first .extend().
The body-node union is supplied per composition, not held in a module-global. z.lazy resolves its getter once and memoizes, so whichever composition parsed first would freeze the union for every later one. Composition and validation covers the per-composition instances that use that memoization instead of fighting it.
Renderers is mapped from SurfaceMap as method signatures, so each env bag is written once and method parameters stay bivariant, which is what lets a pack narrow the collector's node type.
snapshot stays a separate surface even though it shares a renderer, for two reasons: a node can be hidden from snapshots alone with surfaces: ['react'], and a Frame needs a document to draw the body inside.
Packs · Dispatch for what happens to these renderers at render time · Composition and validation for how the schemas compose