Skip to content
HomePagesHomePages template kit

Slot config in `schema`

The `schema` declaration in `section.ts` — the three ways to source a slot, the per-type slot config, editor groups, and the display block.

Declare the schema with defineSchema, picking a per-kind slot builder for each slot. The kind picks the function, so only that kind’s legal options exist and autocomplete is the documentation — a crop on a text slot does not compile.

import {
defineSchema, fact, image, imageAssign, type SectionProps, text, textBlock,
} from "@homepages/template-kit";
export const schema = defineSchema({
label: "Hero",
slots: {
hero_image: image({
label: "Hero image",
crop: { mode: "locked", aspect: 16 / 9 },
fill: imageAssign({ pick: "The most striking exterior shot of the property." }),
}),
headline: text.medium({ label: "Headline", source: fact.property.address, writeback: false }),
blurb: text.long({
label: "Blurb",
cap: 400,
fill: textBlock({ structure: "paragraph", perspective: "third", voice: "Warm and concrete." }),
}),
},
});
export type Props = SectionProps<typeof schema>;

defineSchema returns the same serialized shape the pipeline has always read — the builders are sugar producing today’s slot literals — so SectionProps<typeof schema> and every Renderer.tsx are unaffected by how you declare it.

Every slot names exactly one origin: the source: it is bound to, or the fill: decision that produces its value. Beside slots, a section declares reads for the fact buckets a fill pass would not otherwise carry. Both belong to the fill half of this declaration — Fill rules on a slot’s fill: is where they are written.

A slot is content: something a user can edit or AI can fill. A rendering choice only the section’s own code reads is not a declaration at all — write it as a constant in the renderer, beside the markup it governs.

A section renders two kinds of thing, and only one of them is in this file. Slots are content. Chrome is everything else your markup emits — the “Overview” heading, an eyebrow label, a “View on map” link, a bullet glyph, a metric hairline, a stat’s caption. Chrome is fixed at author time and invisible to both the editor and AI fill.

Chrome is the default, not a declaration. There is no schema entry, no marker component, and no chrome: true flag, because nothing would read one — a declaration no consumer reads is a comment wearing a type. You make something chrome by writing it in Renderer.tsx and not declaring a slot for it:

<section>
{/* chrome — fixed copy, the same on every property */}
<h2 className="text-2xl">Overview</h2>
{/* content — a slot, written fresh per property */}
<Slot.Text slot={slot.summary} as="p" />
</section>

The decision is one question: would a user or AI ever change this per property? A section heading that reads “Overview” on every listing is chrome. A headline written fresh for each property is a slot. Declaring chrome as a slot buys a wire field, an editor card and a fill decision for a constant; leaving real content undeclared makes it uneditable and unfillable.

Two consequences follow. A label beside a value is usually chrome even when the value is a slot — “Beds” is fixed, 3 is not, so declare only the number. And because chrome carries no marker, it is not selectable on the editor’s canvas; that is the intended behavior, not a gap to work around.

A text slot’s size picks the editor’s input

Section titled “A text slot’s size picks the editor’s input”

text.short, text.medium and text.long are the same slot kind at three sizes, and the size steers the editor’s input, not your markup: it picks how tall a field the sidebar draws — short a single-line input, medium a 2-row textarea, long a 5-row one. Your renderer is unaffected and stays free to render a text.long on one line if that is the design.

The size also supplies the slot’s default cap when you declare none — short 120, medium 240, long 2000 characters. Declaring cap explicitly overrides that, and the cap is what the editor enforces as a maximum length and what AI fill writes to:

headline: text.medium({ label: "Headline", cap: 60 }), // 2-row textarea, ≤60 chars

So pick the size for the shape of the text a person will be typing — a headline that wraps to two lines earns .medium even though it is short — and declare cap whenever the design’s real limit differs from the size’s default.

A slot’s source binds to a known fact or a value the section computes — see Slots for why both are drawn from a closed vocabulary:

source: fact.property.address // ✅
source: fact.property.addresss // ✗ compile error: no such fact

The container is how the fact resolves: fact.property.* reads the property root, fact.unit.* the primary unit (or the current row of a record list), fact.contact.* the primary contact card (likewise). Hover an entry for its writability — a writable fact draws the editor’s “Update everywhere” toggle, which pushes the user’s edit back onto the property record; a read-only one does not.

The container comes off the catalog, not off your slot name. A slot may be called whatever an author reasons about — a slot named for a brokerage’s logo and bound to fact.contact.logo_dark is the primary contact card’s dark mark, not a property-level asset, because that field is catalogued under contact. Read the container from the entry you are binding to; a slot name is free to diverge from the field name, and it usually should.

A fact.* path addresses stored fields and derived projections alike. Some entries are values the property record literally stores (fact.unit.beds, fact.property.address_city); others are projections the platform computes on the way out (fact.property.latitude, read off the geocode). The declaration looks identical and resolves identically — there is no separate spelling for a derived fact, and nothing you write changes based on which kind you picked. A projection is read-only where the underlying parts are what a user edits, which is what the entry’s writability tells you.

What a fact.* path never does is assemble a display string for you. The property address is served part by part (address_street, address_unit, address_city, address_state, address_postal), and the contact’s address is served as the single line it was entered as. Deciding which parts share a line, and what separates them, is your section’s job — write it in compute() or in the renderer.

The vocabulary is the kit’s, not yours. Adding to it is a kit release, not a change you can make in a template. See the fact vocabulary for the set; your editor autocompletes the exact fields. For a value the namespace cannot express, write a compute() fn.

A select slot binds the same way as any other type — here fact-bound rather than AI-produced:

listing_status: select({ label: "Listing status", source: fact.property.listing_intent, writeback: true }),

That one source: states two things at once, which is why no values array appears beside it: where the value comes from, and what the editor’s dropdown may offer. A catalogued field’s own domain is its option set, so binding to listing_intent emits both the source binding and the option domain from the single declaration — restating the vocabulary here would be a second copy that can only ever drift from the catalog’s. A vocabulary that answers to no fact is select.of([...]) instead, where you do write the values out.

A list whose element is a select is an ordered set of picks from a fixed vocabulary — see Slots for why a pick’s imagery and prose belongs in the section’s own markup, not here. unique: true forbids picking the same option twice; a unique list’s min/max must each fit inside the vocabulary size, or check rejects the schema (asking for more unique picks than there are options to pick from can never be satisfied).

The vocabulary has to be written out on the element as a non-empty values array. That array is the only thing AI fill reads its allowed picks from, so an element that names a vocabulary resolved somewhere else instead — options.source, naming a provider the editor resolves, or an embedded option catalog — fails check rather than reaching a listing as a slot nothing can ever fill. This bound is on the list element specifically: a top-level select slot, or a select used as a record’s row field, may still name an options.source — one the platform actually registers a provider for, which check holds it to, since a source with nothing behind it resolves an empty dropdown the user cannot open. creatable: true stays available here too — your values are what AI picks from, and the user may still add a key you never enumerated (what a created key renders as is yours to decide).

AI fills such a list with listFill, declared on the list slot itself: count bounds how many picks the model returns and pick is the prose brief. The model chooses only from the element’s own values — the vocabulary is written once, on the element, and never restated on the decision:

amenities: list.of(
select.of(
[
{ value: "pool", label: "Pool" },
{ value: "gym", label: "Fitness center" },
{ value: "pet_friendly", label: "Pet friendly" },
{ value: "concierge", label: "Concierge" },
],
{ label: "Select" },
),
{
label: "Amenities",
min: 3,
max: 6,
unique: true,
fill: listFill({
count: { min: 3, max: 6 },
pick: "The amenities this property genuinely offers, favoring ones a buyer would search for.",
}),
},
),

The second way to source a slot, alongside fact.<container>.<field>: compute((f) => …) binds a slot to a function you write, section-local and closed over nothing but its f parameter.

photo_count: number({ label: "Photo count", source: compute((f) => f.photos.length) }),

f has type Facts — the derive-time view of the property, derived from the same fact registry as the fact.* namespace above and sharing its spellings: the property branch carries every fact.property.* fact (units and contacts included), the primary unit and contact rows sit beside it, and the readable pools — photos, videos, tours, floor_plans, documents, document_notes — the always-on narrative, and the ambient year sit at the top level. The kit projects f from a fixture scenario for local preview; the platform projects the identical shape from canonical property facts when rendering for real — a compute() fn never needs to know which.

defineSchema extracts every compute() call out of the authored schema into a handle→fn map keyed by the slot’s own name — the handle is the slot key itself, nothing else to keep in sync — and rewrites that slot’s source to a plain derived binding, so the function never reaches the emitted schema jsonb or the .strict() Zod parse. fixtures.ts resolves a compute() slot the same way it resolves a fact-bound one (tagged computed provenance); author it and it participates in fixtures for free.

A compute() fn must be synchronous and pure — no fetch, no clock, no randomness, and no Node built-in import (enforced by no-node-builtins-in-contract): it runs inside a dependency-free logic bundle at derive time, not in a Node runtime. An async callback fails to typecheck instead of silently running async.

What f carries is not a curated subset. Every fact the platform holds about a property is reachable from it by construction; the only keys held back are internal plumbing — a storage key, an id tying a derived row back to the upload it came from — which say where a value is kept rather than anything about the property. So the question to ask of a value is whether it is a property fact at all, not whether the kit chose to expose it: amenities, for example, is not one — real data, but a fixture scenario’s own sibling rather than something the platform records about the property. See the fact vocabulary for every entry f carries and the shape each one reads as.

A video slot resolves to one video: by default a rendition the platform hosts and serves, and a third-party player link only where the slot opts into one. A list of videos declares the same slot as its item, and every rule below applies there. It takes the same frame as an image, plus a playback config and a sources declaration, and it takes no crop: a video is selected, never edited, so there is no cropper to steer and no re-edit state on the value.

hero_loop: video({
label: "Hero loop",
frame: { breakpoint: 768, desktop: { aspect: 16 / 9 } },
playback: { autoplay: true, muted: true, loop: true, controls: false, preload: "auto" },
}),

Playback is author-declared per slot because the platform cannot infer it from the asset — a silent hero loop and a narrated walkthrough need opposite behavior. The fields are autoplay, loop, muted, controls, playsinline, and preload ("none" | "metadata" | "auto"); preload: "none" is the bandwidth lever for a below-fold video most visitors never reach. Omit playback entirely and Slot.Video renders a plain controls player.

autoplay requires muted. Every browser blocks unmuted autoplay, so a slot declaring one without the other fails check rather than shipping a video that silently never plays. The type system cannot express the pair, so the check failure is the guard.

sources names where the slot’s video may come from. There are two: "upload", a rendition the platform hosts and transcodes, and "link", a YouTube or Vimeo URL a visitor’s browser plays through the provider’s own embed.

tour_clip: video({
label: "Tour clip",
sources: ["upload", "link"],
frame: { breakpoint: 768, desktop: { aspect: 16 / 9 } },
}),

Omit sources and the slot is upload-only. The narrow default is deliberate: a provider embed honors playback only as far as its player allows — the declared config is mapped onto the provider’s own player params (autoplay, mute, loop, controls off; preload has no embed equivalent), but what the player will not switch off stays, such as YouTube’s start overlay — so a slot declaring a muted autoplay loop becomes a slightly different thing the moment it resolves a link. Widening is therefore something you write per slot, never something a slot acquires. Declare ["upload", "link"] for a slot that takes either, or ["link"] for one that only ever holds a provider embed. sources: [] admits no video at all — it fails check, and the builders reject it at author time. check also rejects an unrecognized source, a repeated one, and sources on any slot that is not a video, wherever it sits — a video declared as a record field or as a list’s item is checked the same way. Every reader of the declaration fails closed to upload-only, so without those failures a typo would just narrow the slot back to hosted video and nothing would say so.

Admitting links changes three surfaces at once. The editor’s video picker grows a paste field for a video link and stops hiding the property’s linked videos, so a user can choose one. AI fill narrows its candidate pool to the sources the slot declares, so an upload-only slot is never assigned a link. And Slot.Video takes its second branch, rendering the provider’s lazy <iframe> in place of a <video>.

A link value a slot does not admit is discarded when the value is read. The declaration is enforced again at bind time because neither the editor nor AI fill can clean a value stored before the slot narrowed: a video slot resolves the blank video instead, so present is false and the renderer sees an empty slot rather than an embed the author forbade, and a LIST of videos drops that member from the array rather than leaving a blank frame mid-grid. The read fails closed in the same direction as the default — anything that is not a sources array listing "link" is upload-only.

A VideoValue is { video_id, url, poster, alt, width?, height?, source?, provider? }. There is no responsive ladder and no mobile variant to pass along, unlike an image. On an upload — which is what an absent source means — url is a single progressive MP4 rendition and width/height carry its intrinsic box. On a link, source is "link", provider names the host, url is the finished embed src, and width/height are absent: a provider publishes its player’s default box rather than the video’s own dimensions, so the slot’s frame owns the box there. Read the fields and hand them to Video, which picks the branch.

AI fills a video slot with videoAssign — the video twin of imageAssign, pooling the property’s videos rather than its photos — and a list of videos with the same listFill that fills every list. Which pool is drawn from is never declared: it is the slot’s own item, so an image list pools photos and a video list pools videos. Both take a pick, and listFill additionally takes an optional count { min, max }; selection reads only text — tags, caption, duration — never the video itself. One option the photo side has is absent by design:

  • No order: "walkthrough". On a photo list it sequences every photo into a room-by-room tour, which presumes a pool large enough to order; a property carries a handful of videos.

A tour slot holds one 3D walkthrough — a Matterport or Biganto model the user picks from the property’s tours. It is its own kind rather than a third video source: playback is meaningless on a walkthrough, and a pool mixing walkthrough videos with tour models is not one you can write a selection brief against.

walkthrough: tour({
label: "3D tour",
frame: { breakpoint: 768, desktop: { aspect: 16 / 9 } },
}),

frame is the only option it takes, and each absence is a separate rule rather than one rule stated four times:

  • No crop — a tour is selected, never edited, exactly as a video is.
  • No playback — a walkthrough has no such config to give, and a tour provider’s player takes none.
  • No sources — a tour is a provider link by construction, so there is no second origin to opt into.
  • No accepts — which providers a tour slot admits is the link provider catalog’s tour kind, widened there rather than per slot.

All four are compile errors, not check failures.

A TourValue is { tour_id, url, poster, alt, provider?, poster_responsive? }. url is the finished embed src — the pasted share link is rewritten once, at resolve, so a renderer reads it directly. poster is the tour’s thumbnail, and it is routinely empty in a way a video’s poster is not: a provider that publishes no usable thumbnail has none to download, so treat the empty state as an ordinary steady state rather than a transient one.

Render it with Slot.Tour for the embed, Slot.Image for the thumbnail, or both — two positions from one selection, so nothing can disagree about which tour the section is showing. There is no separate poster slot to keep in agreement, and no lever over the thumbnail beyond picking a different tour.

A list of tours declares the same slot as its item and needs nothing else: the list editor derives its view from the declared item, so a list of tours is the gallery a list of videos is.

AI fills a tour slot with tourAssign — the tour twin of videoAssign, pooling the property’s tours rather than its videos. It takes a pick brief, and selection reads only text: the provider and the tour’s title.

A url slot may declare an optional accepts, naming either a link kind or a single provider id from the canonical link provider catalog:

tour_url: url({ label: "Tour url", accepts: "tour" }),
matterport_url: url({ label: "Matterport url", accepts: "matterport" }),

The kind vocabulary is tour, video, social, listing, file, map. The provider vocabulary — a single named host, narrower than its kind — is matterport / biganto (tour), youtube / vimeo (video), zillow / redfin (listing), dropbox / drive (file), and maps (map). social is reserved kind vocabulary with no provider registered yet; declaring it as accepts fails check — no url could ever satisfy a kind nothing implements. Omit accepts entirely and the slot takes any url unchecked. Empty is always valid regardless of accepts — clearing a slot is not a validity question, and no slot declares a content floor.

Enforcement is three-layered, all upstream of Renderer.tsx:

  • The editor refuses to commit a value that doesn’t satisfy accepts.
  • AI fill refuses to produce a non-conforming value.
  • bindSlots blanks a value that fails accepts at read time — deep, row fields included — the cleanup layer for a value written before the slot declared accepts, or by any path the first two don’t cover.

A renderer never validates a URL itself: by the time a value reaches your component, it has already passed through all three, or been blanked to "". Read it exactly like any other url slot value.

A document slot holds one file the platform hosts and a visitor opens or downloads — an offering brochure, a disclosure pack, a fee schedule. It declares nothing beyond a label, and that is the point of the kind rather than an omission: nothing about bytes that are never rendered has a crop, a frame, a playback config or a responsive ladder, and which file types are accepted is the upload gate’s business rather than a per-slot lever. A file hosted somewhere else is a url slot with accepts: "file" instead.

No catalogued fact holds a document, so a document slot never takes a source:fill: is the only origin it can declare. A standalone one takes documentAssign, whose pick briefs the model on which of the listing’s attached files belongs here; a list of them takes the same listFill every list takes, and its rows are the model’s picks out of the same pool:

brochure: document({
label: "Offering brochure",
fill: documentAssign({
pick: "The offering brochure — the multi-page PDF presenting the whole property. Not a single-unit floor plan, a fee schedule, or an inspection report.",
}),
}),
disclosures: list.of(document({ label: "Document" }), {
label: "Disclosures and reports",
max: 6,
fill: listFill({
count: { min: 1, max: 6 },
pick: "Every other document a buyer reviews before making an offer — disclosures, inspection and HOA reports, fee schedules, surveys. Leave the offering brochure out; it has its own place on the page.",
}),
}),

The resolved value is { asset_id, url, filename, content_type, size_bytes }: the served bytes and their description, with no transformation step in between. filename is read at resolve time, so renaming an upload renames it everywhere it is attached, and it is also the name the download lands under. Render it with Slot.Document, which puts the url on an anchor and leaves what the link says to your markup.

Documents are optional collateral rather than something every listing carries, so an empty document slot and an empty document list are ordinary states — see the golden scenarios, only one of which ships any.

A poi slot carries one PoiRow, and a list of them carries PoiRow[] — one neighborhood place per row. Seven fields are always present: id, place_id, name, address, lat, lng, and distance_m — metres from the property, captured when the row was produced, and null where it could not be measured. lat/lng are what place a marker on a map; address and distance_m are nullable, everything else in that seven is not.

Every remaining field is optional and never null: rating (1.0–5.0), review_count, type_label (a display label — "Coffee shop", not a machine type like cafe), price_level (an opaque enum string, never a currency amount), website_uri, google_maps_uri, summary (one editorial sentence, already prose), and photo. Render each one conditionally. A place-data source carries most of this for a restaurant and next to none of it for a park, a school or a transit stop, so a layout that assumes a rating leaves a hole across half a neighborhood; an absent field is missing from the row, never present-and-empty. What a row does carry is a snapshot taken when it was produced — ratings drift and businesses close, and nothing refreshes a row on a schedule.

photo is metadata about the place’s image — width, height, attribution — and deliberately persists no URL. A place-data source’s photo reference comes with no stability guarantee, so it is not something a stored row can hold; the row records only what the image is. The dimensions let a layout reserve the right box for it, and the field’s absence is how a template knows the place has no image at all.

The display URL is minted at resolve time from the row’s place_id and arrives on the same object, as photo.url plus a responsive ladder. Both are declared optional on PoiPhoto, and both are absent when the platform has no signing secret configured — so branch on photo.url, never on photo alone. Narrow before use: the kit’s <Image> value types require a non-optional url, so build that value from the narrowed fields or emit the <img> yourself.

Showing a POI photo carries an obligation that is yours to honour in markup: Google’s terms require the image to appear with its author’s credit — the string in photo.attribution — and with a path back to the source, which is what google_maps_uri is for. The kit ships no POI-photo primitive on purpose: the markup around a place is the template’s design, and a primitive would dictate it.

A list of POIs is filled by the same listFill, on the slot. You write only prose: pick says which places belong on this listing, and count: { max } caps how many rows come back. Everything else — composing the place search, fetching candidates, and choosing among them — happens at fill time, through the one list decision’s curate origination:

dining: list.of(poi({ label: "Place" }), {
label: "Dining",
fill: listFill({
pick: "Standout restaurants and cafés within walking distance that a resident would return to, favoring independents over chains.",
count: { max: 3 },
}),
}),

Each decision fills exactly one slot, so a section showing several categories declares one POI list per category, each carrying its own curation. Write pick the way you would brief a local: name the kind of place, then what makes one worth listing. A POI row is a closed shape — the provider writes every field — so no field of one is separately filled.

layout is a sibling of slots and orders the editor’s sidebar. It is a list of rows: a bare slot key is a full-width row, an [a, b] tuple is one 2-up row, and group(id, label, members) collapses several rows into one titled card.

import { defineSchema, group, text } from "@homepages/template-kit";
export const schema = defineSchema({
label: "Contact",
slots: {
headline: text.medium({ label: "Headline" }),
agent_name: text.short({ label: "Agent name" }),
agent_email: text.short({ label: "Agent email" }),
agent_phone: text.short({ label: "Agent phone" }),
},
layout: [
"headline",
group("agent", "Agent", ["agent_name", ["agent_email", "agent_phone"]]),
],
});

group’s id comes first and is required — it is the key a renderer reads off bindGroups(schema).agent, so it stays a literal type rather than being slugified out of the label. The label is the card’s title. Two independent facts, both written, identifier first, exactly as a slot declares its key and its label.

Every position is typed to a real slot key, so a member naming a slot the schema does not declare is a compile error.

A group(...) card also has to be matched in the DOM: each member’s marker must sit inside an element carrying the group’s data-slot-group. Bind the declared groups with bindGroups(schema) and wrap the members’ shared container in <SlotGroup slot={group.agent} …> — see Slot primitives.

layout shapes the cards; slots order sequences them

Section titled “layout shapes the cards; slots order sequences them”

Two different declarations decide what the sidebar looks like, and only one of them is layout.

layout is editor-only, and it shapes cards rather than ordering them. It decides which slots collapse into one card, what that card is called, and how its members sit inside it (full-width rows versus 2-up pairs). None of it reaches a visitor.

The order cards appear in is your slots declaration order — not the order you wrote the layout array in. Each slot maps to its group’s card, or to a card of its own when ungrouped, and the first time a card is reached in declaration order is where it sits.

That order is checked, not trusted: check’s sidebar-order compares it against the page’s real render order — the first-appearance order of the markers in the rendered baseline fixture — and fails when the two disagree. So declare slots in the order your renderer emits them, and the sidebar follows the page for free.

Two things are deliberately left unchecked, because legitimate responsive layouts violate both: the order of members within a card, and whether a card’s members render contiguously. A section may interleave an ungrouped slot among a group’s members without failing.

One display: block — a sibling of slots — owns how a section shows itself: its visibility and its variant, each a select over declared cases with exactly one origin. Both entries are optional, and so is the block; omitting everything reproduces the defaults (always shown, no variant).

import { chooseFrom, compute, defineSchema, select, text } from "@homepages/template-kit";
export const schema = defineSchema({
label: "Tour",
display: {
visibility: select(["visible", "hidden"], {
source: compute((f) => (f.tours.length > 0 ? "visible" : "hidden")),
}),
variant: select(["classic", "editorial"], {
fill: chooseFrom({
pick: "Prefer editorial when the property's story carries the page: ${facts.narrative.description}",
}),
}),
},
slots: {
headline: text.medium({ label: "Headline", when: "editorial" }),
blurb: text.long({ label: "Blurb" }),
},
});

The rules, shared by both entries:

  • The first case is the default AND the failure posture. There is no default parameter. For visibility that makes declared order meaningful: ["visible", "hidden"] fails open, ["hidden", "visible"] fails closed.
  • Exactly one origin. source: compute(fn) decides the case deterministically from the derive-time Facts view — the same signature and purity rules as a slot’s compute(). fill: chooseFrom({ pick }) hands the choice to the AI fill pass, which picks only among the declared cases, steered by the pick prose (${facts.*} interpolation follows slot rules).
  • visibility’s vocabulary is pinned to exactly visible and hidden (either order); you type them for explicitness, and any other case fails check.
  • A variant is the user’s to change unless you declare locked: true — the same lever a slot uses, and legal only on variant: locked on visibility fails check (a composition’s required is the only visibility lock).
  • A fill origin decides from the slice, never from your other decisions. The call is handed the section’s relevance slice and the expanded pick, and nothing else. It runs after every other decision of the section, but it is given none of their output — so a pick about “the photo this section chose” or “the headline it wrote” asks about material that never arrives. Nothing downstream can tell that from a considered answer: the model still names a declared case and the section still renders. Name the evidence instead — widen the slice with reads: for a pool the pick judges, or interpolate the fact itself. A fill entry whose pick does neither fails check.

A slot only some cases show gates on them with when: — a bare case string or an array (when: "multi", when: ["classic", "editorial"]). It serializes to the slot’s variant: { cases } literal, and check rejects a when: naming a case the variant does not declare. A fixture selects a case the same way, with .at("multi").

One variant per section — variant is singular, and a second axis key fails the schema parse. A compute origin travels in the section’s logic bundle exactly as a slot compute does; a fill origin’s pick prose persists in the serialized schema.