# 67 — Adding or editing a presentation renderer Status: **contributor guide for shipped code.** Every file, function, and test named here exists today. The architecture it describes is `docs/design/57-adaptive-presentation-runtime.md`; this document is the how-to that sits beside it. Read this before touching anything that decides how a result looks. The single most common mistake in this area is reaching for a new component or a new `Primitive` variant when a one-line registry entry was the correct change. ## 1. The two-layer model There are exactly two extension layers, and they have very different costs. ### PRIMITIVES — closed, compiled-in vocabulary The `Primitive` enum in `crates/vak-presentation/src/lib.rs` is the host's closed vocabulary. It is the *only* thing a spec may name. Adding a variant is a real code change in three crates plus the client, because: - `crates/vak-delivery/src/adaptive.rs` matches `Primitive` **exhaustively** in `render_node()` — a new variant without a lowering arm fails the build. That is deliberate: it is what guarantees a constrained surface (Telegram, Slack, Discord, plain text) can always lower any primitive to text. - `crates/vak-client-ui/src/components/presentation/GenericSpecRenderer.tsx` dispatches on `node.primitive` in `renderNode()`. An unknown primitive degrades to `renderFallback()` — safe, but visibly generic. The bar for a new primitive is high and is written into the code. Each of the three most recent variants carries a doc-comment saying *why no composition of existing primitives expresses it* (`lib.rs`): - `Recipe` — "Table/KeyValue cannot express the ingredient/step/timer triple without the surface guessing which column means what." - `Research` — "The citation-to-takeaway relationship is lost if this is flattened into a Section plus a CitationList." - `UiPreview` — "The isolation contract (no ambient privileges for the previewed document) is part of the primitive." And the block comment immediately after the enum records the variants deliberately **not** added, with their compositions: ``` metric_grid = `Row`/`Section` whose children (or `each`/`item`) are `Metric` media = `Image` / `Audio` / `Video` / `File` / `Gallery` universal_card = `Entity` or `Section` containing `KeyValue` rows ``` > "A client-side rendering shortcut is not a reason for a host primitive; only > a concept that no composition can express is." Follow that convention. If you cannot write that sentence for your variant, you do not need a variant. ### PACKS — runtime-pluggable, zero code change A pack is data: `PresentationSpec` records wrapped in `StoredPresentation`, carrying a `PresentationOrigin` (`LibraryScope::User`/`Workspace`, owner, optional `plugin_id`, generation). Packs are registered, previewed, and activated at runtime through `PresentationLibrary` / `vak-store`'s `PresentationStore`, and revoked per plugin generation via `revoke_plugin`. A pack **composes existing primitives** and needs no recompilation. This is proven, not asserted: `new_primitives_compile_rich_with_their_own_payloads` and its sibling tests in `crates/vak-presentation/src/seeds.rs` register packs at runtime and compile them through the same generic pipeline. `crates/vak-presentation/src/seeds.rs` is the worked example of pack authoring: 75 disabled starter definitions (45 `EVERYDAY`, 10 `CODING`, 20 `UNIVERSAL`) built from bindings (`$.title`, `$.items`, `$.summary`) over a root primitive chosen by a `match` on the accepted semantic type. Note the file's own opening line: *"Adding a seed never adds a renderer branch."* ### Deciding which layer you need | You want | Layer | Cost | |---|---|---| | A new `semantic_type` shown like an existing one | Registry entry | 1 line | | A reusable layout over existing primitives | Pack / seed | data only, no rebuild | | A shape no composition can express | Primitive | enum + fallback arm + client pair | ## 2. "I want a new semantic_type that reuses an existing primitive" This is the common case and the cheap one. **Step 0 — confirm the type is actually server-registered.** A client `STRUCTURED_RENDERERS` key that the server never validates is dead code: any answer using it is rejected by `SkillRegistry::validate()` (`vak-delivery/src/skills.rs`) before it reaches the client, "unknown type." Check `vak_delivery::skills::built_in_semantic_types()` directly for the exact accepted set — do not assume a name is live just because a renderer or a `system-prompt.md` example mentions it. `crates/vak-core/src/presentation_tools.rs`'s `every_registered_semantic_type_across_all_shapes_renders` test exercises every one of those types end-to-end and will fail loudly if a shape's tool claims a type the registry doesn't accept, but it doesn't enumerate them for you — call `built_in_semantic_types()` for that. As of 3.4.5 the registry holds 97 types. This check exists because `STRUCTURED_RENDERERS` grew ten keys across two commits (85408ebc, 1d34cd6b) without a matching registry update — `decision_matrix`, `criteria_matrix`, `tradeoff_analysis`, `metric_chart`, `comparison_chart`, `telemetry.chart`, `telemetry.metric`, `weather`, `lifestyle.recipe`, `lifestyle.culinary_recipe` — silently unreachable for a full release cycle until caught while building the `emit_*_card` tool-calling path (2026-09-18) and registered. **Step 1 — add the registry entry.** `STRUCTURED_RENDERERS` in `crates/vak-client-ui/src/components/PresentationRenderer.tsx` is the single registry; its header comment states the invariant: *"Every named semantic type resolves through this single registry."* Point your key at an existing `buildXSpec`: ```tsx "my.new_type": ({ data }) => , ``` Several keys sharing one builder is the intended pattern, not duplication. `recipe.card`, `recipe`, `recipe_summary`, `lifestyle.recipe`, and `lifestyle.culinary_recipe` are five registry lines that all resolve to the same `buildRecipeSpec` / `renderRecipe` pair. Likewise `trend`, `timeseries`, `metric_chart`, and `comparison_chart` all resolve to `buildChartSpec`, with `bar_chart` differing only by the forced `"bar"` override argument, and `decision_matrix` / `criteria_matrix` / `tradeoff_analysis` all resolving to `buildTableSpec` with different default titles. Conditional routing is allowed where the payload genuinely decides the shape — `meal_plan` picks `buildRecipeSpec` when `steps` or `ingredients` are present and `buildTimelineSpec` otherwise. Do not extend this to guessing: an *unregistered* type must stay unregistered. `StructuredRenderer` deliberately refuses to infer a renderer from payload shape, because "that lets a new contract silently take over an older card and makes regressions invisible." **Step 2 (optional) — add a seed** in `crates/vak-presentation/src/seeds.rs`: one tuple in `EVERYDAY`/`CODING`/`UNIVERSAL` and, if it needs a specific root, one arm in `seed()`'s `match accepts`. Bump the seed-count assertion in `seed_pack_is_rich_disabled_and_validated_by_host_types`. Seeds register **disabled**; activation stays an explicit operation. **Step 3 (optional) — teach the model.** Only if the type is worth actively prompting for. The `capability_contract` block in `crates/vak-core/src/system-prompt.md` is the *only* model-facing location in the repo that teaches `semantic_type` emission — onboarding text, capability snapshots, and tool descriptions carry none. Untaught-but-accepted types are still listed by name: `presentation_catalogue_section()` in `crates/vak-core/src/prompts.rs` generates the "Also accepted as `semantic_type`" list from `vak_delivery::skills::built_in_semantic_types()`, so a registered type is discoverable even with no worked example. Add a worked example only when the payload shape needs demonstrating — and see §5 before you write one. ## 3. "I want a genuinely new primitive" The checklist below is what commit `65e5951d` actually did for `Recipe`, `Research` and `UiPreview`. Do all of it. 1. **Justify it in a doc-comment on the variant** in `crates/vak-presentation/src/lib.rs`, in the established form: name the compositions that *almost* work and say what they lose. If the answer is "nothing, it would just be more convenient on the client", stop — add a registry entry or a pack instead, and consider recording the rejection in the "Deliberately absent" comment after the enum. 2. **Add the fallback arm** in `crates/vak-delivery/src/adaptive.rs` `render_node()`. The build will force this. Do not widen an existing catch-all `|` arm to swallow the new variant unless plain bullet lowering is genuinely the right text projection — the three domain primitives each got their own arm because they each have structure worth preserving in text. 3. **Add the client pair** in `crates/vak-client-ui/src/components/presentation/GenericSpecRenderer.tsx`: a `case "my_primitive":` in `renderNode()`'s switch, a `renderMyPrimitive(node, surface)` that honours both `"full"` and `"compact"` surfaces, and an exported `buildMyPrimitiveSpec(data): AdaptiveRenderNode` adapter. Keep the adapter tolerant — the file's own convention is "try several known field names, degrade to an empty/placeholder value, never throw on missing or malformed input." 4. **Wire the registry** — one or more `STRUCTURED_RENDERERS` keys pointing at the new builder, per §2. 5. **Add a seed** in `seeds.rs` so the primitive is reachable end to end, and update the counts. 6. **Prove it compiles through, don't just assert it.** The pattern is `new_primitives_compile_rich_with_their_own_payloads` in `seeds.rs`: feed a realistic payload through the real `compile()` and assert the result is `CompiledPresentation::Rich` **whose root primitive is the new one**. Its comment states the point: "The enum additions are reachable end to end, not decorative." A test that only asserts the enum variant exists proves nothing. `seeds_reach_the_newly_added_primitives` is the companion check. 7. **Teach the model** in `system-prompt.md` if the primitive is something the model should reach for, per §2 step 3. ## 4. Verification checklist Run all of these before calling the change done. Fast inner loop, narrowest first: ```sh cargo build --workspace --exclude vak-desktop cargo test -p vak-presentation # spec/compiler/seed-count/primitive reachability cargo test -p vak-delivery # exhaustive primitive lowering, skills validator cargo test -p vak-core # prompt-phrase pin + catalogue tests ``` Then the repo-wide gate from `AGENTS.md`, which is what CI runs and what a commit is judged against: ```sh cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace scripts/check-version.sh && python3 scripts/check_doc_paths.py ``` `check_doc_paths.py` matters here specifically: this area's docs cite crate paths, and it fails on any that no longer exist. Client: ```sh cd crates/vak-client-ui npx tsc --noEmit npm run build # BOTH bundles; dist-web/ is committed and must match source ``` `npm run build` writes `dist/.src-manifest` and the server build re-verifies it, so a source change without a rebuilt bundle fails the build by name. Never weaken that check — regenerate the bundle. **If you changed rendering behaviour visibly, run the browser check.** The permanent harness is `crates/vak-client-ui/tests/presentation.html` + `presentation.tsx`; see `crates/vak-client-ui/tests/README.md`: ```sh npm run dev:web -- --port 1421 agent-browser --session vak-render-check open http://localhost:1421/app/tests/presentation.html agent-browser --session vak-render-check wait --load networkidle agent-browser --session vak-render-check eval 'window.runChecks()' ``` It iterates `structuredRendererTypes` — derived from `STRUCTURED_RENDERERS` itself — so a newly registered type cannot bypass the completed-turn contract, and it asserts one rendered slot per registered type. For a *migration* rather than an addition, the technique used throughout the `GenericSpecRenderer` migration is stronger and worth repeating: stand up a throwaway harness that renders the old component and the new primitive path side by side over the same payload — happy path **and** edge cases (missing field, `null`, wrong type, empty array) — and diff the resulting DOM for byte-identical output. Then boot the real app and check the console is clean. "It looked right in a screenshot" is not this. ## 5. Common mistakes (all of these actually happened here) **Teaching the model a field name the renderer never reads.** The `system-prompt.md` `decision` example taught `{options:[{name,score,pros,cons}]}`. The timeline builder's field-guessing never recognises `options`, so the example silently degraded to a raw JSON-dump fallback — a broken example that no test caught, because nothing tied the prompt's payload shapes to the builders that consume them. It now teaches `choices[{name,reason,status}]`, the shape actually read. **Cross-check every prompt example against the real `buildXSpec` field list and, for validated types, against `crates/vak-delivery/src/skills.rs`.** Do not write the example from the payload you wish existed. **Guarding a renderer on a field and rendering nothing when it is absent.** The old registry guarded every chart key with `Array.isArray(data?.series) ? : <>`, so a payload whose `series` was missing, `null`, or a non-array rendered *literally nothing* — no card, no error, no fallback. `buildChartSpec` now normalises any shape to an empty series list and `renderChart` shows its "No data points supplied." placeholder. The same class of bug existed in `test_matrix` (the old component called `.filter` on a possibly-absent `tests`) and in `diff` (a raw patch under `diff`/`patch`/`raw_diff`/`content` rendered an empty card). **An empty or malformed payload must reach a visible empty state, never a blank node and never a throw.** Test the empty array, not just the populated one. **Stringifying a structured value instead of carrying it.** `JSON.stringify` on a nested value is a last-resort leaf projection for display, and it is lossy — the node schema is `primitive` / `props` / `children` and structure belongs in children. If you find yourself stringifying to get something on screen, you are usually missing a nesting level; `SpecNode` lets any primitive nest any other, which is exactly why `metric_grid`, `media` and `universal_card` did not need enum variants. **Defaulting a status field.** `buildTestMatrixSpec` deliberately does **not** default a test's status to `"passed"`. A defaulted status turns missing data into a positive claim, which is the presentation-layer version of upgrading an unsupported claim into observed evidence. Leave it empty and let the renderer show it as unknown. **Adding a primitive for a rendering convenience.** See §1. Three variants were added in `65e5951d`; three more were explicitly rejected in the same commit and the rejection reasoning was committed as a code comment so the next contributor does not re-litigate it. ## Related - `docs/design/57-adaptive-presentation-runtime.md` — the architecture: spec, compiler, primitive vocabulary, pack lifecycle, scoping and revocation. - `docs/design/30-render-architecture.md` — ledger → projection → delivery, everything upstream of the client renderer. - `docs/design/30-output-engineering.md` — channel lowering and surface capabilities. - `AGENTS.md`, "Code rules" — the binding one-paragraph version of this contract. ## Layout stress check Short fixtures hide layout bugs: text that escapes its container only shows up with real-world lengths (long titles, snippets, URLs). Open the card harness with `?stress=1` (`/app/harness.html?stress=1` under `npm run dev:web`) and it pads every string in every fixture with long prose plus an unbroken URL. Then measure, per element, whether it spills its parent or the card (`scrollWidth > clientWidth` with `overflow: visible`, or a bounding box past its parent's edge), ignoring intentional scrollers (`.diff-code-area`, `.semantic-table-wrap`) and hover-only popovers. Run it at a narrow viewport as well. New cards should come out with zero findings. Flex children that hold `nowrap` text need `min-width: 0`; headers with actions need `flex-wrap: wrap`.