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; and the host-facing name, which the prompt does not print. |
icon.ts |
The host-facing glyph, exported as icon and built with slideIcon (Icons). |
examples.ts |
example plus examples, used by the conformance harness and the authoring prompt. |
styles.ts |
The primitive's Distillate module, reading only slideDistillery.tokens. slideStyleModules in src/stylesheet.ts lists it for slideStylesheet(). |
react.tsx |
The React renderer, exported as react. It serves the snapshot surface too. |
index.tsx |
definePrimitive, renderer wiring, and the text, markdown, and slack renderers. |
index.test.ts |
Schema rejections and every surface's output for the primitive. |
fit.ts |
Only for a primitive that picks a size step: its one step function, <name>Step(node, layout), and the estimates it reads (Size steps). |
A primitive holds no values of its own. Everything it renders lives in its group under SLIDE_THEME (src/theme/components/<group>.ts, assembled by src/theme/theme.ts), which its styles.ts reads.
Each primitive's catalog.ts and examples.ts ship in lib beside its renderers, because the authoring prompt and the conformance harness read them from the built package. What the build 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. The scales live in src/theme/base.ts: spacing from space (keyed by pixel value on the fixed canvas, space.px48), type from the type roles or font.size / font.weight / font.tracking / font.lineHeight, corners from radius, strokes from stroke, and scheme-varying color from color and inverse. Each primitive then has its own group in src/theme/components/ that composes those into the values its module reads. A raw px(...) belongs there only when the value genuinely has no place on a scale, and it says why. Do not type a literal into a styles.ts or a renderer that the theme could name — bulletList.crossGlyph is a theme value because a marker is a value, not a decoration.
A glyph more than one primitive prints lives once, in the shared glyph group (src/theme/components/shared.ts): arrow, separator, dash, and termJoiner. A component group points at it (code.traceArrow and split.arrowGlyph are both glyph.arrow, frame.separator is glyph.separator) rather than typing the character again.
This does not reach every literal in a module: 1fr, minmax(0, …), 50%, and a fixed repeat(2, …) are CSS mechanics rather than design tokens, and stay inline. Nor does it reach a length drawn from authored data, such as a bar's share of its track in slideBars, which goes in an inline style.
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 and inverse 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. One that branches on a count or a grid line builds its domain with countKeys and reads a key with countKey, since a variant key must start with a letter.
Every primitive has a catalog.name and an icon, unique across the pack, and pack_contract.test.ts fails without them. The SDK's Names and icons has the rules an icon must pass and why its values are exempt from the rule above.
slideIcon(hue, draw) from src/theme/icon_hues.ts builds each one, and that file is the one source for every fallback colour. It draws a bg tile and sets the strokes on the root, so draw returns only the glyph's shapes. Keep the set reading as one family:
- Draw inside
2..14, with the pack's1.5round strokes andrx=".5"on filled blocks. - Paint the one emphasised element
accent, bodiesmuted, and linesfg. - Take the hue of the primitive's group: indigo for numbers and data, blue for structure, teal for flow, violet for code and rendered output, yellow for words, green for lists.
- Draw text as short strokes; an icon cannot hold
<text>.
The snapshot 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 one <svg> the pack draws is the Isomer mark (src/render/logo.tsx), which inlines docs/logo.svg as ISOMER_LOGO_PATHS with brand-fixed fills; render/logo_marks.test.ts keeps the paths in step with the file. Everything else — rails, arrows, rules, check marks — is a bordered block, so it follows the theme on both surfaces.
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.- A fixed literal is one value for both schemes. The image has no stylesheet behind the attribute, so it varies by scheme only when its value comes from the frame's palette for the image's mode,
env.theme; Light and dark shows how. This pack draws only the brand-fixed logo inside an<svg>, so it keeps scheme-varying color outside one.
Text fields that accept `code` and **strong** render through src/render/marks.tsx: marksReact on the React surface, plainText for text, stripMarks for fit estimates, marksMarkdown for the Markdown builder, and marksSlack or marksRichText for Slack. Strong is bold ink in regular-weight copy and underlined primary in text that is already bold or display-sized (headings, taglines), where extra weight would not show, so it is never color alone. Say so in the field's .describe(), so a model knows the field takes them; src/marks_descriptions.test.ts fails when the two disagree.
import { md } from '@elastic/isomer-sdk/markdown';
import { catalog } from './catalog';
import { examples } from './examples';
import { schema } from './schema';
import { react } from './react';
export const slideHeadingPrimitive = definePrimitive({
type: 'slideHeading',
catalog,
examples,
schema,
renderers: {
react,
text: ({ title, lede }) =>
[plainText(title).toUpperCase(), lede && plainText(lede)]
.filter(Boolean)
.join('\n'),
markdown: ({ title, lede }) => [
md.heading(1, ...marksMarkdown(title)),
...(lede ? [md.paragraph(...marksMarkdown(lede))] : []),
],
slack,
},
});
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.
An authored string is lineText() or wrappedText() from src/primitives/authored_text.ts, never a bare z.string(). Both cap its length at authoredTextMaxLength (10,000 characters), an input-size guard against far too much text rather than a layout limit, so validation refuses it before any renderer parses marks or estimates a size. Whether text fits is the layout check's job, not the schema's.
There is no snapshot renderer, and adding one is the mistake this pack exists to rule out. The snapshot 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.
The snapshot surface measures width, height, and their min- and max- forms to the border. A rule that sets one on a padded or bordered box declares box-sizing: border-box, so a browser draws the same box; src/theme/modules.test.ts fails when a rule sets both without it.
checkLayout reports a box that runs past its room. It does not see two boxes of one node overlap, or text run past its own box. So text that must stay on one line is a display: flex box with white-space: nowrap, in a track or flex item that cannot shrink under it: too long, it pushes its node past the room, where it is reported. An auto grid track takes its item's width only while the item has no min-width of its own.
The text, markdown, and slack renderers live in index.tsx. A markdown renderer returns the SDK's md builder content, never a string, so the serializer escapes each value where it lands. A one-line authored value goes through oneLine (src/render/one_line.ts) on the text and Slack surfaces; src/content_parity.test.ts fails when a line break survives on any of them. Every primitive has a slack renderer, and src/registry.test.ts fails when one does not.
Slack clamps header, section, field, and context text well below authoredTextMaxLength. Build those blocks through src/render/slack_text.ts (slackHeading, slackSection, slackContext, slackFields, slackMarksSection, slackMarksContext), which keep the block when it holds the whole text and fall back to rich text when it does not; src/slack_limits.test.ts checks each such slot at its limit and one past it. Build the fallback with slackRichText; the SDK's Slack envelope splits each section, quote, or preformatted element past sectionTextChars into adjacent ones of its type, so no text is lost, and leaves a list item whole, since a second item would add a bullet. Bold a mrkdwn label with slackBold, which keeps edge whitespace outside the asterisks, and link rich text with richTextLinked, which links only what slackLinkUrl accepts; pass it the renderer's env.baseUrl, as link takes it, so a relative href links when the host gives a base.
mrkdwn has no escape for *, _, ~, or a backtick, and Slack reads < and &-style escapes even inside code, so authored text that holds them goes to Slack as literal rich text instead. slackSection, slackContext, and slackFields take the authored text their mrkdwn carries as a required last argument, each a MrkdwnSource: a plain string, { marks } for a field whose code and **strong** marks marksSlack draws, or { code } for code in a code block. When mrkdwnKeeps(sources) says Slack would not print one as written, they return the fallback, which must hold the same text as literal rich-text runs. Pass [] only for text the pack writes itself. slackMarksSection, slackMarksContext, and slackCaption pass their own. Send code through slackCodePanel(source, caption?, strong?), which falls back to rich_text_preformatted when the source holds a backtick fence, a <, or an &, <, or >. src/content_parity.test.ts reads every Slack block back the way Slack does and fails when a field that holds any of these, added to any example, does not come back as authored.
Spread nodeAnchor(context, { type }) from @elastic/isomer-sdk on the renderer's root element. It renders nothing unless a host asks for anchors, and it is what checkLayout pairs measured boxes to nodes by. slideFrame also spreads layoutRoom(context) on its body, and slideSplitLayout on each pane's column, which its grid shrinks to the height the footnote leaves, so checkLayout measures the nodes inside against that room rather than the whole canvas.
A primitive that picks its own size reads the room it has with slideLayout(context) from src/primitives/layout.ts, a StepLayout: width and height in pixels, and crowding. Its renderer hands that to the step function in its fit.ts and does no sizing of its own. A container gives its children theirs with withLayout(context, { width, height }), after taking out what it draws around them; Size steps lists who sets and reads it.
Measure text with measureText(text, role, width?) from src/primitives/size.ts, which returns { lines, widest } in pixels. role is the same type role the primitive's styles.ts hands typeRole(), at the step being tried ({ ...agenda.title, size: agenda.titleSizes[step] }), so the estimate and the stylesheet read one value. A role's transform (font.transform.uppercase) and whiteSpace (font.whiteSpace.nowrap) are what typeRole() emits as text-transform and white-space; set them on the role, not as a literal in the module, and the measure follows. Its family and weight pick the advances: mono and regular by column, semibold and heavier by Inter ExtraBold's glyph classes. Whitespace runs and line terminators collapse to one space, as CSS and takumi collapse them; a no-break space stays a glyph. A word wider than its line breaks between glyphs, as overflow-wrap: anywhere does on the deck root, one glyph a line at least, so a zero width counts one line a glyph. Strip marks before measuring, or measure marked text with measureMarks(text, role, width?, strong?), which sets each run as marksReact draws it for the same strong: code in mono, in its own case without its chip under 'primary' and with its chip's codeInset and border at each end otherwise, and strong in bold except under 'primary'; a word that spans runs stays one word, and each space is priced in the face of the run it sits in. It also returns codeLines, how many of the lines it lays out hold a chip. lineBox(role) is one line's height, and marksHeight(text, role, width?) is marked text's height: its lines, and each line holding a chip grown by the chip's codePaddingY and border at top and bottom.
wrappedLines, proseLines, and monoLines are measureText for ExtraBold, Regular, and mono at a pixel size, never under one line; textColumns counts columns after whitespace collapses, and rowLoad sums it. sizeForLines breaks no word, so its words pack whole. sizeForWords(text, role, sizes, width) is the largest step at which no word of text is wider than width, and under a nowrap role the whole text; sizeForMarkedWords(text, role, sizes, width, strong?) is the same for marked text, each run set as measureMarks sets it. styledText(text, role) is the text as the role's transform prints it, for a load that counts characters. slideCode and slideCodeTrace compare displayColumns against their column cap directly: its lines are white-space: pre, and its schema refuses line terminators and tabs. src/primitives/size.test.ts checks the uppercase, nowrap, per-step, whitespace, long-word, zero-width, and marked-run cases, and spot-checks measureText against takumi.
Which fields become children follows one rule:
- Nested nodes are always children.
- A collection of records brands a child component, named for its item without the
Slideprefix. - A primitive's one authored text is its text children.
- Scalars and enums stay props.
A collection field you add brands a child component the same way. Grids are the exception: slideTable, slideMatrix, and slideQuadrant keep their grids as props, because each cell is addressed by its position, and an element per cell adds markup without adding meaning.
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.
items: fromChildren(
'territory',
z.array(territorySchema).min(1).max(4).describe('Owners, left to right, in equal columns. 1 to 4.'),
{ text: 'body' }
),
slideJsx then includes SlideTerritoryGroup.Territory, typed from the array element. text: 'body' copies leftover text children onto that field. An explicit items prop wins over children. Placing <SlideTerritoryGroup.Territory> directly under <Composition> throws, because it is not a body node. A child is a member of its primitive, so name it for its role there, without the primitive's name: item, cell, node. A name is unique across the primitive's children at any depth, so a quadrant's cells and the chips inside them are SlideQuadrant.Cell and SlideQuadrant.Item.
A string field uses fromTextChildren the same way, with collapseWhitespace: false when newlines matter.
Required text that is missing throws IsomerError with code MISSING_AUTHORED_TEXT. JSX children on a primitive with no field to fill from them, such as a table, whose grid is props, throw UNEXPECTED_CHILDREN rather than being dropped. Two fields in one primitive's children that capitalize to the same member throw DUPLICATE_AUTHORED_CHILD when the shim is built. Two types that capitalize to the same component name, such as slideStat and SlideStat, or a type named composition, throw DUPLICATE_PRIMITIVE_TYPE.
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). slideFrame, slideSplitLayout, slideStackLayout, titleSlide, slideWindowLayout, and the three renders are that branch. Every other primitive's schema is its only declaration.
Nested nodes arrive as JSX children, never as an element inside a prop object. A container with one node slot fills it from its own children: <SlideStackLayout>…nodes…</SlideStackLayout> fills items, and <SlideWindowLayout> fills body. A window's body is SlideWindowChild: every content primitive except { type: 'slideWindowLayout' }. A window nested through another container still fails validation. A stack's items are SlideContentNode, so a frame never nests. A container with several slots, or with props per slot, brands one field of sub-elements, and each sub-element's children are its nodes. slideSplitLayout is that case: panes is fromChildren('pane', …, { toItem }), and toItem fills each pane's items through parseChildren, left then right by index. toItem receives children among the props and strips it. Brand the standalone schema, not schemaFor, and pass both type arguments to definePrimitive (definePrimitive<SlideSplitLayoutNode, typeof schema>) so the brand reaches slideJsx.
<SlideSplitLayout divider="arrow">
<SlideSplitLayout.Pane label="Before">
<SlideCode>curl …</SlideCode>
</SlideSplitLayout.Pane>
<SlideSplitLayout.Pane label="After" tone="primary">
<SlideBulletList>
<SlideBulletList.Item>One call</SlideBulletList.Item>
</SlideBulletList>
</SlideSplitLayout.Pane>
</SlideSplitLayout>
Several branded fields share one children list, and each takes the elements of its type. Elements that match none of them fill the primitive's one child slot, when that slot is not itself branded. slideFrame is that case: its body nodes are the element children, and <SlideFrame.Footer> fills footer.
titleSlide brands two optional children. <TitleSlide.Definition term="ledger n."> fills definition, its sentence the child, and <TitleSlide.Aside> fills aside from its one slide node.
Containers (frame, split, stack, title) 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 }) => renderTextChildren(node.items, scope),
markdown: (node, { scope }) => renderMarkdownChildren(node.items, scope),
slack: (node, { collector, scope }) =>
renderSlackChildren(node.items, scope, collector),
},
schemaForreplacesunresolvedBodyNodeSchemain the child slot with the runtime's full discriminated union so foreign nodes validate correctly.bodyNodes(src/primitives/define.ts) builds that slot fromcontentNode, which refuses a nestedslideFrame, and carries over the field's.describe(), which.extend()would otherwise drop.childrenexposes nested nodes to the duplicate-id checker, the empty-surface checker, and node anchors. An embedded slide is not a child: the renders validate theirbodythroughschemaFor, declare nochildrenfor it, and draw it underwithoutAnchors, so anchors and builds pair only the host slide's nodes.src/render/children.tsdispatches each child through the render scope, so a foreign node inside a container renders rather than disappearing.renderMarkdownChildrenembeds each child's builder content throughscope.renderMarkdownContent. Without a containerslackrenderer, the dispatcher sends the container's Markdown to Block Kit and every child goes with it, including one that has a nativeslackrenderer.
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.snapshotHeight reports its drawn height to a frame that sums node heights. slideFrame sets both because it owns the 16:9 canvas. slideSplitLayout has its own content only when a pane has a label or the split has a footnote; slideStackLayout draws nothing and sets neither.
catalog.ts is what a model reads when it chooses a primitive, rendered into the authoring prompt beside the pack's guide and rules (src/agent_guide.ts). Write it for that reader:
purposestates the reader's need the primitive meets, not what it draws.useWhenholds two or three author intents, each a situation rather than a shape.- Every
avoidWhenentry names the primitive to use instead, bytype("The points split by who owns them; use slideTerritoryGroup."). Name only registered primitives:src/agent_guide.test.tsfails when the prompt, schema descriptions and examples included, names a type the pack does not register. exampleuses neutral subject matter. A model copies the example's register, so an example about Isomer produces slides about Isomer.- Write a cross-field constraint as
.check(crossRefine(…))fromsrc/primitives/cross_field.ts, not a bare.refine: Zod skips a bare one once any field fails, so an author would fix one error only to meet the next. Because it runs after a size bound fails, have it pass without reading a field whose count or length is already over its cap. A check that adds its own issues iscrossSuperRefine(refine, rules), and a rule on one field alone isstatedRefine. z.toJSONSchemadrops every refinement, so each of these takes arule(rulesforcrossSuperRefine): the sentence, word for word, that a description the authoring schema keeps states it in, on the field it constrains or in the catalog'sdescription. A shared rule is a constant both use, ashrefRuleis forboundedHref().src/stated_rules.test.tswalks every primitive's schema, fails on a custom check made any other way, and fails when a rule is missing from that primitive's authoring schema.
Each primitive's index.test.ts covers its schema rejections and every surface's output. These pack-level tests run over the whole registry, so a new primitive is covered by registering it:
src/content_parity.test.tschecks that text, Markdown, Slack, and HTML carry every authored string of every example, reading Markdown back throughmdast-util-from-markdownwith GFM and Slack mrkdwn back to its text; that entity-like text such as<and unpaired`or*print as authored, one field at a time; and that a line break in a one-line field reads as a space on each surface.src/slack_limits.test.tsrenders each Slack slot whose limit is below its schema cap at the limit and one past it, and expects the authored text whole both times and noslackLimitProblems.src/examples/fit.test.tsrenders every example on a slide (a frame as it is; a title, section, or closing slide alone on an inverse frame; a statement or quote alone on a page frame; anything else under a heading and lede on a page frame), measures it with takumi, and expects no finding fromcheckLayout, which measures the body's nodes against the frame's body and a split pane's nodes against the pane; the fullestagendaSlideandslideDefinitionsexamples are measured again below a two-line heading and lede, the room their load budgets are set against. Give each primitive an example at the most items, lines, or panels its schema takes, in representative copy. That proves the most structure fits, not every schema-valid string: a string may hold far more than a slide draws, whichcheckLayoutreports. The same file measures statement, quote, agenda, and definitions at the most each holds atlandmon a slide with no heading, and keeps a fanout, a list, an agenda, the fullest roadmap, and the eight-entry tree that run past the body below the tallest heading, so a regression in that report fails. EachslideGraph,slideRoadmap, andslideTimelineexample is also measured alone, under the tallest heading, as a title aside, and in a labelled split pane with and without a heading above it, where the step it picks must fit whereversdoes.src/conformance.test.tsruns the SDK's conformance harness.src/primitives/authored_text.test.tschecks thatlineText()andwrappedText()take exactlyauthoredTextMaxLengthand refuse one more, and that every string in every example refuses one more.src/heading_levels.test.tsfails when an example's HTML headings and Markdown headings differ in level or order.src/stated_rules.test.tsfails when a custom check does not go throughsrc/primitives/cross_field.tsor its rule is not in the primitive's authoring schema.
The tests that rasterize need @elastic/isomer-image-takumi and @fontsource/* as devDependencies. src/primitives/slide_command/copy.test.ts runs under jsdom through a // @vitest-environment jsdom pragma, so a pack lifted out of this monorepo installs jsdom too.
Beside its own folder, a new primitive has five hand-maintained stops:
- Its folder,
src/primitives/<type>/. On the catalog, setdescriptionwhen a cross-field rule needs restating; it is the authoring$defdescription. A primitive that sizes itself exports its step fromfit.ts(Size steps), and one that builds wraps its definition inwithBuild(Builds). src/registry.ts: the import and its place inslideDeckPrimitives, alphabetically.src/body_node.ts: its node type in theBodyNodeunion, in the same order.src/pack_authoring.ts: its group inslidePrimitiveGroups.src/theme/components/<group>.ts, its theme group, and its place inSLIDE_THEMEinsrc/theme/theme.ts.src/stylesheet.ts: the import of its style module and its place inslideStyleModules.
The builds follow from the registry: slideBuilds reads build. The stylesheet does not, so a bundle that imports only slideStylesheet keeps the style modules without every renderer; slideStyleModules references each module by value, which keeps it in that bundle while the package declares no side effects. The theme cannot follow from the registry: SLIDE_THEME is what creates slideDistillery, which every styles.ts reads, so a theme taken from the registry would need the modules it creates.
import { myNewPrimitive } from './primitives/my_new_type';
export const slideDeckPrimitives = [
…,
myNewPrimitive,
] as const;
The registry and the union 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 guards the first four stops: a type-level assertion that the two lists agree in both directions, assertPackRegistrationComplete, which reads src/primitives/ and fails when a directory is in neither list, a check that every registered type is in exactly one group, and a check that every primitive has a slack renderer. Forgetting one fails the build with the lines to add. src/stylesheet.test.ts guards the sixth: with the registry loaded, CSS from slideStyleModules alone must match slideStylesheet(), and a rolldown bundle of the stylesheet alone must keep every style file. After a build, pnpm check:module-graph bundles slideStylesheet alone from the built ESM entry and requires the same CSS the CommonJS build emits.
slideJsx is the primitives as components, so a deck reads as markup instead of a hand-built node tree. It is buildJsxShim(slideDeckPrimitives) from @elastic/isomer-sdk/author, prebuilt in src/jsx.ts, with src/jsx.test.ts failing when the registry and the shim drift:
import { slideJsx } from '@elastic/isomer-primitives-slides';
const { Composition, SlideFrame, SlideHeading, toComposition } = slideJsx;
const composition = toComposition(
<Composition title="Refunds settle in two days">
<SlideFrame>
<SlideHeading>Refunds settle in two days, not five</SlideHeading>
<SlideFrame.Footer
brand="Ledger"
sectionNumber="02"
section="Settlement"
/>
</SlideFrame>
</Composition>
);
A whole-slide layout takes its parts as children. TitleSlide.Definition fills definition and TitleSlide.Aside its one node. AgendaSlide.Section fills an agenda, SectionSlide.Item a section's slides, and ClosingSlide.Link and ClosingSlide.Path a closing slide. Quote, statement, and source text is the child:
<TitleSlide eyebrow="A grocery delivery platform" title="Crate" tagline="One order. Every store.">
<TitleSlide.Definition term="crate n.">
Everything a customer means to buy, carried from whichever store can fill it.
</TitleSlide.Definition>
<TitleSlide.Aside>
<SlideBulletList marker="check">
<SlideBulletList.Item>Charges the card once.</SlideBulletList.Item>
</SlideBulletList>
</TitleSlide.Aside>
</TitleSlide>
<AgendaSlide>
<AgendaSlide.Section number="01" title="Why returns cost us" count="3 slides" />
<AgendaSlide.Section number="03" title="The new returns flow" current />
</AgendaSlide>
<SectionSlide number="02" title="Settlement">
<SectionSlide.Item>Refunds settle in two days, not five</SectionSlide.Item>
<SectionSlide.Item href="#slide-12">Checkout failed for 41 minutes</SectionSlide.Item>
</SectionSlide>
<ClosingSlide title="Start here">
<ClosingSlide.Link label="Docs" href="https://example.com/ledger/docs">
example.com/ledger/docs
</ClosingSlide.Link>
<ClosingSlide.Path title="Issue a refund">The refunds guide, then `POST /refunds`</ClosingSlide.Path>
</ClosingSlide>
<QuoteSlide source="Priya N." context="Customer interview, March">
I stopped checking my bank app once the refund email said **which day** it would land.
</QuoteSlide>
<StatementSlide>A refund is **a promise**, not a transaction.</StatementSlide>
<SlideSource>Support tickets tagged “refund”, January to June</SlideSource>
A container that arranges primitives inside a slide ends in Layout. SlideStackLayout and SlideWindowLayout take those primitives as children. A window's direct child cannot be another SlideWindowLayout.
<SlideStackLayout spacing="tight">
<SlideBulletList>
<SlideBulletList.Item>One nightly run.</SlideBulletList.Item>
</SlideBulletList>
<SlideCommand>npm run settle -- --nightly</SlideCommand>
</SlideStackLayout>
<SlideWindowLayout chrome="terminal" title="~/shop — release">
<SlideCommand>npm run settle -- --nightly</SlideCommand>
</SlideWindowLayout>
A content primitive takes its list as children named for it, without the Slide prefix. Each child's sentence is its text, and its short fields are attributes. A heading's title is its text, and lede stays an attribute. A list of plain strings takes one child per string:
<SlideHeading lede="Same ledger, fewer days.">Refunds settle in **two days**</SlideHeading>
<SlideBulletList marker="check">
<SlideBulletList.Item>Charges the card once.</SlideBulletList.Item>
<SlideBulletList.Item>Refunds in `two days`.</SlideBulletList.Item>
</SlideBulletList>
<SlideColumns highlight={1}>
<SlideColumns.Column title="Poll">Lags by one interval.</SlideColumns.Column>
<SlideColumns.Column title="Push" tags={['webhook']}>Immediate.</SlideColumns.Column>
</SlideColumns>
<SlideDefinitions>
<SlideDefinitions.Definition term="hold">Money set aside, not yet taken.</SlideDefinitions.Definition>
</SlideDefinitions>
<SlideList label="Limits">
<SlideList.Item term="rate">100 requests a second.</SlideList.Item>
</SlideList>
<SlideRoadmap>
<SlideRoadmap.Column title="Now" status="Shipped" current>
<SlideRoadmap.Item title="Holds">Set money aside at checkout.</SlideRoadmap.Item>
</SlideRoadmap.Column>
<SlideRoadmap.Column title="Next" status="In design">
<SlideRoadmap.Item title="Splits">Pay two stores from one order.</SlideRoadmap.Item>
</SlideRoadmap.Column>
</SlideRoadmap>
Code keeps its whitespace. A command is its text. A SlideCode's text is its source: write it as a template literal, and the line break after the opening backtick, the indentation before the closing one, and the indentation every line shares are dropped. JSX itself collapses line breaks in bare text, so multi-line source needs the template literal. Its file, language, and lineNumbers are attributes. A slideCodeTrace takes two SlideCodeTrace.Panel children, left then right, each with the same attributes and its source as its text, joined by an arrow. A panel's lineNumbers, as in EuiCode, is true or { start, highlight }: highlight is a range string such as '2, 5-7' of line numbers as the gutter shows them, so start shifts them, and the gutter's columns come out of every line's budget. Text, Markdown, and Slack print each line after its number. A diff's text is its source, read the same way as a panel's. Its remove and add are range strings in the same form as highlight, but they count the lines of the body from 1, removed ones included, since a removed line has no number in the gutter; no line is both. Its lineNumbers is true or { start }, and its one gutter numbers the file after the change, leaving removed lines unnumbered. Text and Slack print the numbers before each line's - or +; Markdown keeps a plain unified diff. A transcript takes SlideTranscript.Turn children, each turn's text keeping its line breaks:
<SlideCommand label="Install">npm install @elastic/isomer-runtime</SlideCommand>
<SlideCode file="refund.ts" language="ts" lineNumbers={{ highlight: '2' }}>
{`
const refund = async (id) => {
await ledger.hold(id);
return ledger.settle(id);
};
`}
</SlideCode>
<SlideCodeTrace>
<SlideCodeTrace.Panel file="config.yaml" language="yaml">
{`
checkout:
timeout: 30s
`}
</SlideCodeTrace.Panel>
<SlideCodeTrace.Panel file="client.ts" language="ts" lineNumbers={{ start: 41 }}>
{`
createClient({
timeout: config.checkout.timeout,
});
`}
</SlideCodeTrace.Panel>
</SlideCodeTrace>
<SlideDiff file="refund.ts" language="ts" remove="2" add="3" lineNumbers>
{`
export async function refund(id: string) {
return settle(id);
return ledger.settle(id);
}
`}
</SlideDiff>
<SlideTranscript label="Tool call">
<SlideTranscript.Turn role="user">Refund order 1042.</SlideTranscript.Turn>
<SlideTranscript.Turn role="model" format="code">{'refund({ order: 1042 })'}</SlideTranscript.Turn>
</SlideTranscript>
A number's sentence is its text, and the figures are attributes. A bar's label is its text. A table and a matrix keep their grids as props: a row is a list of cells, and an element per cell would be longer than the array it replaces. A quadrant takes four SlideQuadrant.Cell children, top-left to bottom-right, each holding its SlideQuadrant.Item chips; a cell with none is empty.
<SlideStat value="2" unit="days">Refunds settle **three days** sooner.</SlideStat>
<SlideDelta before={{ label: 'Before', value: '5 days' }} after={{ label: 'After', value: '2 days' }}>
The ledger writes first.
</SlideDelta>
<SlideStats>
<SlideStats.Stat value="41" label="Minutes">Checkout was down.</SlideStats.Stat>
<SlideStats.Stat value="0" label="Lost">Every order was kept.</SlideStats.Stat>
</SlideStats>
<SlideBars>
<SlideBars.Bar value={12} highlight>Checkout</SlideBars.Bar>
<SlideBars.Bar value={4} detail="p95">Search</SlideBars.Bar>
</SlideBars>
<SlideQuadrant x={{ low: 'Easy', high: 'Hard' }} y={{ low: 'Minor', high: 'Major' }} highlight={0}>
<SlideQuadrant.Cell label="Quick wins">
<SlideQuadrant.Item>dark mode</SlideQuadrant.Item>
</SlideQuadrant.Cell>
<SlideQuadrant.Cell label="Big bets">
<SlideQuadrant.Item>same-day</SlideQuadrant.Item>
</SlideQuadrant.Cell>
<SlideQuadrant.Cell label="Fill-ins" />
<SlideQuadrant.Cell label="Money pits">
<SlideQuadrant.Item>own fleet</SlideQuadrant.Item>
</SlideQuadrant.Cell>
</SlideQuadrant>
A diagram's parts are children of their kind. Two kinds share one children list, and each element goes to its own field: a pipeline's SlidePipeline.Step and SlidePipeline.Span, a sequence's SlideSequence.Actor and SlideSequence.Message, and a lanes diagram's SlideLanes.Lane and SlideLanes.Note. Ids, indexes, and short lists of chips or steps stay attributes. A graph draws no arrow it is not given a place for, so its arrows follow from its nodes: the main row joins left to right, and a node placement="above" or "below" names the main-row node it joins in of, its arrow pointing into the row unless direction="out":
<SlideFanout source="Order">
<SlideFanout.Target name="Ledger">Holds the money.</SlideFanout.Target>
<SlideFanout.Target name="Email" tone="accent">Tells the customer.</SlideFanout.Target>
</SlideFanout>
<SlideGraph>
<SlideGraph.Node id="cart" term="Cart">What the customer picked.</SlideGraph.Node>
<SlideGraph.Node id="ledger" term="Ledger">Where the money is held.</SlideGraph.Node>
<SlideGraph.Node id="pricing" term="Pricing" placement="above" of="ledger">
Offers, fixed at checkout.
</SlideGraph.Node>
</SlideGraph>
<SlideLanes join="Checkout">
<SlideLanes.Lane label="Card" steps={['Authorize', 'Capture']} />
<SlideLanes.Lane label="Wallet" tone="accent" steps={['Redirect']} />
<SlideLanes.Note title="Card">Two steps, no redirect.</SlideLanes.Note>
</SlideLanes>
<SlideLayers>
<SlideLayers.Layer name="Storefront" owner="Web">What people see.</SlideLayers.Layer>
<SlideLayers.Layer name="Orders API" owner="Platform">Takes and checks orders.</SlideLayers.Layer>
<SlideLayers.Layer name="Ledger" owner="Payments" chips={['holds', 'refunds']} />
</SlideLayers>
<SlidePipeline>
<SlidePipeline.Step title="Hold" />
<SlidePipeline.Step title="Settle" />
<SlidePipeline.Span from={0} to={1} tone="primary" label="Ours" title="Ledger">We own both steps.</SlidePipeline.Span>
</SlidePipeline>
<SlideSequence>
<SlideSequence.Actor id="app">App</SlideSequence.Actor>
<SlideSequence.Actor id="api">Orders API</SlideSequence.Actor>
<SlideSequence.Actor id="bank">Bank</SlideSequence.Actor>
<SlideSequence.Message from="app" to="api">Pay</SlideSequence.Message>
<SlideSequence.Message from="api" to="bank">Charge</SlideSequence.Message>
</SlideSequence>
<SlideTerritoryGroup>
<SlideTerritoryGroup.Territory title="Host" tone="accent">Owns routing.</SlideTerritoryGroup.Territory>
</SlideTerritoryGroup>
<SlideTimeline>
<SlideTimeline.Item label="09:00" channel="Pager" heading="Checkout paged">On call answered.</SlideTimeline.Item>
<SlideTimeline.Item label="09:12" channel="Slack" heading="Cause found">A bad deploy.</SlideTimeline.Item>
<SlideTimeline.Item label="09:41" channel="Deploy" heading="Rolled back">Orders flowed again.</SlideTimeline.Item>
</SlideTimeline>
<SlideTree root="checkout/">
<SlideTree.Item name="refund.ts">Issues refunds.</SlideTree.Item>
</SlideTree>
An annotated render takes its SlideRender and its SlideAnnotatedRender.Pin children side by side; a pin's sentence is its text. A render grid takes SlideRenderGrid.Tile children, each captioned by its text. The slide a render or grid embeds is its other children, branded fromNodeChildren because it is drawn, not walked:
<SlideAnnotatedRender>
<SlideRender slide="checkout" surface="snapshot" />
<SlideAnnotatedRender.Pin x={20} y={30} title="Heading">The claim, in one line.</SlideAnnotatedRender.Pin>
</SlideAnnotatedRender>
<SlideRenderGrid>
<SlideHeading>Refunds settle in two days</SlideHeading>
<SlideRenderGrid.Tile surface="react">Hosts</SlideRenderGrid.Tile>
<SlideRenderGrid.Tile surface="slack">Channels</SlideRenderGrid.Tile>
</SlideRenderGrid>
toComposition walks the tree back into the plain Composition value every surface renders from — JSX is authoring sugar, not a second representation.
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: 'slideHeading', 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.