Authoring a primitive
Each primitive lives in its own directory under src/primitives/<type>/. Folder-local exports use the definePrimitive keys (catalog, examples, schema, react) so the index can import them without aliases. The React file is a (node, env) renderer, not a component.
| File | What it holds |
|---|---|
schema.ts |
The declaration. The Zod schema, and export type Node = z.infer<typeof schema> & PrimitiveNode. Field notes live on .describe(), which also reaches the agent JSON Schema. |
types.ts |
Only when the primitive declares schemaFor. Those nodes hold the body-node union, so the type stays a hand-written interface. |
catalog.ts |
Agent-facing copy: purpose, useWhen, avoidWhen, one example. |
examples.ts |
example plus examples, used by the conformance harness and the authoring prompt. |
react.tsx |
The React renderer, exported as react. It serves the svg surface too. |
index.tsx |
definePrimitive, renderer wiring, and inline text/markdown unless those surfaces are large enough for their own files. |
A primitive holds no values of its own. Everything it renders lives in its group under SLIDE_THEME (src/theme/theme.ts), which the CSS modules read.
Each primitive's catalog.ts and examples.ts ship in dist beside its renderers, because the authoring prompt and the conformance harness read them from the built package. What tsconfig.build.json excludes is src/examples/**: the worked deck and its snapshot output, which are type-checked but never published.
Every length on the spacing, type, or radius scale and every scheme-varying color has exactly one authoring source, and that source is always SLIDE_THEME in src/theme/theme.ts. A spacing value comes from size (a 4px grid, m being the 16px base), type from font.size / font.heading / font.weight, tracking from font.tracking, a corner from radius, and a scheme-varying color from color. Each primitive then has its own group — frame, cards, bullets — that composes those into the values its CSS module reads. A raw px(...) belongs there only when the value genuinely has no place on the scale, and it says why. Do not type a literal into theme/modules.ts that the theme could name — bullets.markerGlyph is there because a marker is a value, not a decoration.
This does not reach every literal in theme/modules.ts: a hairline border (1px solid), tab-size: 2, a color-mix() percentage, and a fixed grid-template-columns: repeat(2, …) are CSS mechanics rather than design tokens, and stay inline.
Distillate theme leaves are not color-only: a plain string becomes a CSS custom property with identical light/dark values; lightDark(light, dark) is the scheme-varying color helper; a ScaleToken (cq / scaleToken, wrapped here as px and literal) inlines as a literal and cannot vary across schemes or overlays. This pack uses ScaleToken for everything outside color because the 16:9 canvas is fixed — a host that wants different geometry replaces the frame (docs/document.md, docs/theme.md), not a token. Reach for a string leaf only when a value is meant to be host-themeable.
A CSS module that branches on an enum field uses variants(domain, factory) with the domain array already in src/theme/variants.ts. Do not add a private switch.
The image surface renders this pack's React tree against this pack's stylesheet, so a primitive styles itself with Distillate handles and needs no second renderer. That holds everywhere except inside an inline <svg>, which is the one place the stylesheet does not reach.
An image backend does not lay out SVG children as part of the document. It lifts the element out, hands the markup to an SVG parser as a standalone sub-document, and composites the result — so a <rect> in there is no longer a node the stylesheet can match, and the document's custom properties are not in scope for it. A class that works perfectly in HTML paints nothing, and the shape rasterizes black.
So a shape inside an <svg> carries its own paint as a presentation attribute, beside the class it uses in the browser:
<path d="…" fill="#00BFB3" />
Both surfaces read the one they can. A presentation attribute carries no specificity, so any class rule beats it and HTML stays scheme-aware through the custom property; the image has only the attribute, so that is what it draws. The header mark inlines docs/logo.svg as ISOMER_LOGO_PATHS. Those fills are brand-fixed literals, the same way ELASTIC_LOGO_PATHS is, so the paths do not take a scheme class.
Two traps worth naming:
var(--x, #fallback)is worse than#fallback. SVG parsers generally do not implement custom properties, and rather than taking the fallback they discard the whole declaration — so the shape ends up black, which is the failure the fallback looked like it was preventing. Write the literal.- The attribute cannot vary by scheme. There is no stylesheet behind it, so it is one value for both. Treat a mark drawn this way as brand-fixed, the way
ELASTIC_LOGO_PATHSalready is, and keep scheme-varying color for everything outside the<svg>.
import { catalog } from './catalog';
import { examples } from './examples';
import { schema } from './schema';
import { react } from './react';
export const slideTitlePrimitive = definePrimitive({
type: 'slideTitle',
catalog,
examples,
schema,
renderers: {
react,
text: (node) => [node.eyebrow, node.title].filter(Boolean).join('\n'),
markdown: (node) => `## ${node.title}`,
},
});
Leave both type arguments inferred. Passing TNode alone widens the schema and drops field brands. The node type is z.infer<typeof schema> & PrimitiveNode, exported from schema.ts.
There is no svg renderer, and adding one is the mistake this pack exists to rule out. The image surface lays out the react tree against the pack's stylesheet, so a second hand-authored tree is a second thing to keep in sync and a second thing to get wrong. Drawing inside an svg covers the one place that equivalence stops.
Keep text and markdown inline when they are a few lines; add text.ts / markdown.ts only when they grow.
A field filled from JSX children is branded on the schema. fromChildren and fromTextChildren are identity wrappers: z.infer is unchanged, and buildJsxShim reads the brand for the child component and for parsing. Describe the inner schema first — .describe() clones the schema and drops the brand.
cards: fromChildren(
'slideCard',
z.array(cardSchema).min(1).describe('Cards to lay out.'),
{ text: 'body' }
),
buildJsxShim(slideDeckPrimitives) then includes SlideCard, typed from the array element. text: 'body' copies leftover text children onto that field. An explicit cards prop wins over children. Placing <SlideCard> directly under <Composition> throws, because it is not a body node.
A string field uses fromTextChildren. slideCode keeps newlines:
code: fromTextChildren(z.string().min(1).describe('Source to display.'), {
collapseWhitespace: false,
}),
<SlideCode label="Composition" language="ts">{`const spec: Composition = {
type: "view",
body: [node],
};`}</SlideCode>
Required text that is missing throws IsomerError with code MISSING_AUTHORED_TEXT. Two primitives that brand the same child type with different item shapes throw DUPLICATE_AUTHORED_CHILD when the shim is built.
One branch: a primitive that declares schemaFor also hand-writes its node type. The body-node union is injected per composition, so it cannot appear in a static schema — which is why schemaFor exists — and z.infer cannot name that cycle (TS2456, TS7022). Brand the child field the same way. When one child element is not the array element, pass toItem; its props annotation is the child component's props. slideFrame, slideSplit, and slideStack are that branch. Every other primitive's schema is its only declaration.
Containers (frame, split, stack) need three more fields:
schemaFor: (bodyNodeSchema) =>
schema.extend({ items: bodyNodes(bodyNodeSchema, schema.shape.items) }),
children: (node) =>
node.items.map((child, index) => ({ node: child, path: `items[${index}]` })),
renderers: {
react,
text: (node, { scope }) => renderChildren(node.items, scope, 'text'),
markdown: (node, { scope }) => renderChildren(node.items, scope, 'markdown'),
},
schemaForreplacesunresolvedBodyNodeSchemain the child slot with the runtime's full discriminated union so foreign nodes validate correctly.bodyNodes(src/primitives/define.ts) builds that slot and carries over the field's.describe(), which.extend()would otherwise drop.childrenexposes nested nodes to the duplicate-id checker and the empty-surface checker.renderChildren(src/render/children.ts) dispatches each child throughscope.renderText/scope.renderMarkdown, so a foreign node inside a container renders rather than disappearing, and drops empties before joining.
A container that also draws chrome of its own sets two more. hasOwnContent: () => true keeps it on a surface when every child is hidden there, and metrics.svgHeight reports its drawn height to a frame that sums node heights. slideFrame sets both because it owns the 16:9 canvas; slideSplit and slideStack set neither, since they draw nothing and take their height from their children.
src/registry.ts is hand-maintained. Add the import and include it in slideDeckPrimitives:
import { myNewPrimitive } from './primitives/my_new_type';
export const slideDeckPrimitives = [
…,
myNewPrimitive,
] as const;
Also add the node type to the BodyNode union in src/body_node.ts. The two lists cannot be collapsed into one — the container types.ts files import SlideContentNode, so deriving the union from the registry closes that cycle at the value level (TS7022). src/registry.test.ts holds both guards: a type-level assertion that the two lists agree in both directions, and assertPackRegistrationComplete, which reads src/primitives/ and fails when a directory is in neither list. Forgetting a step fails the build with the lines to add.
buildJsxShim(slideDeckPrimitives) turns the primitives into components, so a deck reads as markup instead of a hand-built node tree:
import { buildJsxShim } from '@elastic/isomer-sdk/author';
const { Composition, SlideFrame, SlideTitle, toComposition } =
buildJsxShim(slideDeckPrimitives);
const composition = toComposition(
<Composition title="Title slide">
<SlideFrame chapter="01 · Primitives" footer="Elastic" layout="title">
<SlideTitle title="One composition, every surface." size="hero" />
</SlideFrame>
</Composition>
);
toComposition walks the tree back into the plain Composition value every surface renders from — JSX is authoring sugar, not a second representation. src/examples/deck/index.tsx is the full worked version, covering every primitive's shim.
A host that wants to preview one primitive outside a full deck uses StandaloneSlideNode, which wraps a single content node in the deck-root scope and inlines the pack's stylesheet:
import { StandaloneSlideNode } from '@elastic/isomer-primitives-slides';
<StandaloneSlideNode node={{ type: 'slideTitle', title: 'Preview' }} />;
slideStylesheet() returns the same CSS as a standalone string, for a host that mounts the pack's React tree itself and wants to inject the <style> tag separately rather than through StandaloneSlideNode.