Skip to content
HomePagesHomePages template kit

Add a variant

Render one section two ways, with the case seeded by facts or AI and switchable by the user unless locked.

Render one section two ways, with the case seeded before fill ever runs — from the property’s own facts, or by an AI decision — and switchable by the user in the editor unless you lock it.

A variant is a presentation choice the user may overturn — something decides its starting case, and the editor then offers the switch unless the section says otherwise. Declare it under display:, a sibling of slots, as a select over its cases. Add select and compute to the section’s existing kit import first — neither is one of the names the scaffolded file takes.

// section.ts — a `display` key, beside `slots`
display: {
variant: select(["single", "multi"], {
source: compute((f) => (f.property.units.length > 1 ? "multi" : "single")),
}),
},

Three rules do all the work:

  • The first case is the default — and the posture on failure. There is no default parameter; order the cases so the first one is the one you want when nothing decides otherwise.
  • Exactly one origin. source: compute(fn) seeds the case deterministically from the derive-time facts view — the same view, and the same purity rules, as a slot’s compute(): no clock, no randomness, no fetch. fill: chooseFrom({ pick }) hands the choice to the AI fill pass instead, steered by the pick prose; the model picks only among the cases you declared. A pick has to name the evidence it judges — a bucket the section reads:, or an interpolated ${facts.*} — because the call is handed no other decision’s output (Display). Declare one or the other, never both.
  • Users switch variants by default. The seeded case is a starting point; the editor renders a selector and the user’s pick sticks. Add locked: true when the split is not an opinion — this section’s single-vs-multi follows the building’s unit count, so it locks:
// section.ts — a deterministic, non-negotiable split
display: {
variant: select(["single", "multi"], {
source: compute((f) => (f.property.units.length > 1 ? "multi" : "single")),
locked: true,
}),
},

The renderer receives the resolved case, already decided, as its variant prop — narrowed to the union of the declared cases. Destructure it beside slots in the component’s signature, then branch on it:

// Renderer.tsx — one section, two shapes
<div>
{variant === "multi" ? (
<Slot.List slot={slot.units} as="ul" className="divide-y">
{bindItems(slot.units).map((unit) => (
<SlotGroup slot={unit} as="li" key={unit.index}>
<Slot.Text slot={unit.fields.unit_label} as="span" />
</SlotGroup>
))}
</Slot.List>
) : (
<Slot.Text slot={slot.summary} as="p" />
)}
</div>

Add bindItems and SlotGroup to Renderer.tsx’s kit import for the collection branch.

The conditional is an addition, not a replacement. It goes beside the markup the section already renders — the standalone Slot.List the collection guide adds stays exactly where it is. Branch over what the section shows extra. Move an ungated collection inside a case instead and check fails on markers no fixture state repairs: a record list’s per-row markers are proven fixture by fixture, so every fixture that resolves the other case leaves its own rows unmarked.

A slot only one case shows gates on that case with when: — a bare case string, or an array for a slot two cases share (when: ["single", "multi"]). The build gate checks every when: against the declared cases, so a mistyped case fails check rather than shipping a slot that quietly never fills.

That gate is also the trap worth planning for. Every fixture resolves to the FIRST case unless it says otherwise, so the multi branch’s markup renders in neither preview nor check until some fixture reaches it — and a slot the renderer shows only under multi reaches no fixture’s marker coverage at all. Add a state that selects the other case with .at("multi")Preview a section’s structural edge cases is where .at() lands — and both branches get rendered, checked, and previewable.

Two additions across section.ts and Renderer.tsx, plus select and compute on the section’s existing kit import and variant on the renderer’s own props destructure. The second addition goes beside the markup already there, not swapped in for it — and the fixture state that renders the other case is one more addition, in Preview a section’s structural edge cases:

// section.ts — beside `slots`, at the top level of `defineSchema`
display: {
variant: select(["single", "multi"], {
source: compute((f) => (f.property.units.length > 1 ? "multi" : "single")),
}),
},
// Renderer.tsx — added beside the markup already there, wherever the branch belongs
<div>
{variant === "multi" ? (
<Slot.List slot={slot.units} as="ul" className="divide-y">
{bindItems(slot.units).map((unit) => (
<SlotGroup slot={unit} as="li" key={unit.index}>
<Slot.Text slot={unit.fields.unit_label} as="span" />
</SlotGroup>
))}
</Slot.List>
) : (
<Slot.Text slot={slot.summary} as="p" />
)}
</div>

Verify with the author loop.

  • no-nondeterminism — a compute origin reads the clock or a random source instead of the facts it is handed.
  • missing-slot-marker — a slot the renderer shows only under one case, with no fixture that resolves to that case, never reaches the DOM with its marker in any fixture.