Preview data in `fixtures.ts`
The `fixtures` declaration in `fixtures.ts` — `defineFixtures`, the golden scenarios, the fill-by-source rule, and why media is stated by id.
See Fixtures and scenarios for why fixtures build on the kit’s shared scenarios rather than invented sample data, and why that data never ships.
fixtures.ts is the one module nothing in the published import graph
reaches — the golden scenario corpus stays out of a published bundle by
structural unreachability, never by an author’s discipline. It imports
{ schema } from ./section and exports one named fixtures: a
base fixture — a fully-specified baseline, every slot the scenario can plausibly
fill carrying a real value — plus any number of named states for the structural
cases your section is designed to handle (a missing logo, a long headline, an
empty list).
What the emptiness coverage does and does not reach
Section titled “What the emptiness coverage does and does not reach”You do not write a state to prove a slot survives being empty — the checks synthesize that corpus from the schema, one fixture per declared slot with that slot emptied. There is no exemption for any slot — a renderer receives an empty slot whatever the schema declares. Knowing the two boundaries is what tells you when a designed state is still owed:
- Only slots your
basefills. A slot already empty inbasegets no synthesized fixture — it would duplicatebase. The consequence runs the other way and is the one that bites: nothing ever populates a slotbaseleaves empty, so that slot’s filled branch is rendered by no fixture at all and can break with every check green. Fill it inbaseand write the empty case as a state, never the reverse. - One slot at a time, off
base. Each synthesized fixture empties exactly one slot, inbase’s own resolved variant context. A defect that needs two slots empty together, or an empty slot under a non-default variant case, is not covered — that is a designedstatesentry.
The recommended way to build that export is defineFixtures, from the same entry the
golden scenarios live on:
export const fixtures = defineFixtures( schema, (scenario) => ({ base: scenario("luxuryMultiUnit", { // Only the slots nothing else can resolve: no `fact` source and // no fill-spec decision producing them. Everything else is filled for you. blurb: "A bright corner home moments from the Emerald Necklace.", }), states: { // A state can source from a different scenario entirely, rather than // overriding literals — every slot resolves against sparseSingleFamily's // own facts, never luxuryMultiUnit's (see "states are complete" below). sparse: scenario("sparseSingleFamily", { blurb: "A classic center-hall colonial with an updated kitchen and " + "original hardwood floors throughout.", }), }, }),);defineFixtures hands your callback a scenario bound to the schema you passed it,
so every fixture below is typed off that schema with nothing to annotate.
scenario(key, authoredSlots) names one of the kit’s golden scenarios
and supplies the slots that need an authored value; defineFixtures resolves
every other slot — facts and compute() values — to the
exact value the fill pipeline would bake for that scenario, by walking the
schema and (when you pass it) the fill spec. A slot bound to a fact or a compute() fn
is not a valid authored key at all (author a fact- or compute()-bound slot and
TypeScript refuses to compile — use .override() if you really need to replace its
value), a slot key the schema does not declare is refused by both authoredSlots and
.override(), and every other slot’s value is typed to its SlotValue — a
blurb: 42 on a text slot is a compile error, same as an unknown scenario key.
A FixtureSpec (what scenario() returns) is immutable — each method returns a new
spec, so a shared base can fork per state with no bleed:
| Method | Does |
|---|---|
.at("multi") |
Selects this fixture’s display-variant case — a bare case string, one per section. A case the display: block does not declare throws at resolve time, never a silent miss |
.override({ slot: value }) |
Replaces a slot’s resolved value with a literal — the one escape hatch for a value no scenario produces. Wins over everything else, including a slot gated off by the active variant |
.label("Solo broker, no logo") |
The human name the preview lists this fixture under. Without one, a state is listed by its own key |
Label a state whenever its key cannot say what it is. A states key has to
be an identifier; what the state is for usually is not — “solo broker, no brand
logo” cannot be a key, and a one-word key does not say it. The label is dev-preview
presentation only: nothing downstream reads it, and it never reaches a published
page.
states are complete by construction, not merged onto base. A
state resolves independently, from its own scenario(...) call — so a state built
from sparseSingleFamily gets sparseSingleFamily’s facts throughout, never a base
value from luxuryMultiUnit leaking through an omitted slot. There is no exception:
scenario isolation is what keeps one state’s brand out of another’s.
Cross-section link targets are not authored. A renderer reads
nav.anchors[targetKey], and a standalone render — the playground, check, a
snapshot — has no composed page to resolve against, so the kit synthesizes the map:
every target resolves to #<target>, and the links are dead ends in a lone section
regardless. A composed page builds the real map from its composition, and check’s
nav contract is what proves every target a renderer reaches for is actually composed.
A variant-gated slot auto-omits when the active case isn’t in its cases —
exactly as if you had left it out of authoredSlots yourself: no value, no
provenance entry. .override() still wins even for a gated-off slot.
A scenario that cannot supply a fact a slot needs fails loudly, at load, naming
the scenario, the fact path, and the slot — never a silent null or an empty string
standing in for missing data.
Slots resolved this way are tagged with provenance — fact, computed,
authored, or override — and template-kit check surfaces it: a render defect’s
diagnostic lists exactly how each slot’s value was obtained, so you don’t need a
separate inspection step to tell a baked fact from something you typed.
Not every slot resolves automatically. A slot with no fact/compute()
source and no fill-spec decision (a plain content slot), or one behind a
fill decision, stays an authored key: fill it
the same way you always did, grounded in the scenario’s facts, never lorem ipsum.
An image slot states the asset id a real page holds — never a URL. The preview server resolves it to a url, an alt fallback, and a responsive ladder, exactly as the platform does when the page is published.
Fill an authored slot by its source
Section titled “Fill an authored slot by its source”defineFixtures resolves a fact-bound or compute()-bound
slot for you; everything else is an authored key you fill yourself. Don’t invent an
answer — the scenario already has one. The fill-by-source rule says where to find it,
keyed to the same source a slot declares in the schema, or to the decision that fills it:
| Slot’s fill-spec source | How the fixture fills it |
|---|---|
a fact (fact.<container>.<field>) |
read it off the scenario — s.facts.address.display, primaryContact(s).fields.phone — or let defineFixtures resolve it |
compute() |
a helper — propertyMetrics(s), poisByCategory(s), contactCards(s) — or let defineFixtures resolve it |
| a fill decision | a short sample fill grounded in the scenario’s facts — a headline about this property, never lorem ipsum or invented facts |
A fixture that gets this wrong is worse than a missing one: a slot bound
fact.unit.beds whose fixture value doesn’t match s.facts.units[0].beds teaches
nothing about the layout it’s supposed to preview.
The helper functions project a scenario’s raw facts into the shape a fixture usually wants, without re-typing the scenario’s data:
| Helper | Returns |
|---|---|
primaryContact(s) |
The scenario’s primary contact (ContactFixture, position 0 by construction) — throws if the scenario has none |
contactCards(s) |
Every attached contact, in order (ContactFixture[]) |
poisByCategory(s) |
The scenario’s POIs as PoiRows, bucketed by category (Record<PoiFixture["category"], PoiRow[]>) |
propertyMetrics(s) |
Beds/baths/sqft/price off the lowest-position unit, and *Min/*Max/priceStart across all units |
propertyMetrics returns kit fact vocabulary — bedsMin, priceStart and siblings,
never a template’s own slot names, so the kit never learns what a section calls its slots.
Map it to your slots at the call site: beds_min: m.bedsMin.
s.photos, s.pois, s.amenities, s.contacts, s.media, s.documents, and s.facts
(the raw PropertyFacts) are also readable straight off the scenario for slots that don’t
need a helper’s projection — a gallery slot can bind directly to s.photos, and a
document list maps s.documents. A contact’s
org name and logos (logo_light_id/logo_dark_id) live on ContactFixture["fields"],
not on a sibling “brand” object — read them off primaryContact(s).fields or
s.contacts.
s.contacts is duplicated from facts.contacts, not a replacement for it — both hold
real data in every scenario, so a fill-spec that reads or iterates facts.contacts has a
real counterpart to check a fixture against. facts.address mirrors the real address
contract field for field (display, street, street_no_type, city, state, postal,
and an optional unit), so reading address.city resolves against a real value too — note
street_no_type drops the street-type suffix (“845 Pearl” for “845 Pearl Street”), so never
derive one from the other. Address lines are yours to compose: bind the parts your
section needs and join them in compute() or the renderer, rather than splitting
address.display — the pipeline bakes the parts, never a pre-assembled line.
A fact-bound record list bakes its own rows
Section titled “A fact-bound record list bakes its own rows”A record list bound whole-row to a fact array (source: fact.property.units,
source: fact.property.contacts, source: fact.property.comparables) is not an
authored key. Its rows come from the same
projection the platform bakes with, so an entry that wants the scenario’s own rows
states nothing — and cannot state them: the slot is source-bound, so naming it in
scenario() is a compile error.
Reach for .override() only when the state is ABOUT rows no scenario produces — a
slice, or a row with one field emptied. Take those rows from bakedRows, the build
callback’s second argument, so a synthetic state is still the real projection’s
output rather than a hand-typed approximation of it:
export const fixtures = defineFixtures(schema, (scenario, bakedRows) => { const units = bakedRows("luxuryMultiUnit", "units"); return { // `units` and `contacts` are stated nowhere — both resolve from the scenario. base: scenario("luxuryMultiUnit", { summary: "…", blurb: "…", headline: "…" }), states: { // One unit — a slice no scenario ships. one_unit: scenario("luxuryMultiUnit", { summary: "…", blurb: "…", headline: "…", }).override({ units: units.slice(0, 1) }), }, };});A row states only the fields it sets
Section titled “A row states only the fields it sets”Every field of a record row is optional in a fixture. Anything a row leaves
out is filled on resolution with the blank a bake would carry there — "" for
text and url, null for number and image, [] for a collection, and, for a
fact-bound slot, the projection’s own value for the keys no declaration names (a
unit’s unit_id, its position). So a state that means “one contact, every
optional field empty” says exactly that:
export const fixtures = defineFixtures(schema, (scenario) => ({ base: scenario("luxuryMultiUnit", { summary: "…", blurb: "…", headline: "…" }).override({ contacts: [{ id: "li_solo", name: "Ana Solo" }], }),}));Optional is not open: a key no row declaration names is refused exactly as an undeclared slot key is, so a misspelled field is a compile error rather than a prop that rides silently into the rendered section.
Every field of a row is typed by its own declaration, so it states exactly what
that slot states one level up: an image field takes media.photo(id), a
video field media.video(id), a tour field media.tour(id), a document
field media.document(id), and a
field-level list of any of them an array of those references. A row of a
fact-bound list is the one that also takes resolved values — its keys are the
ones the bake delivers, which is what lets a bakedRows row feed straight back
into .override(). Where a declaration and the bake disagree there, the bake
wins: a unit’s floor_plans is declared as an image and baked as an ordered list
of plans, so a row states the list.
Media is stated by id, never by URL
Section titled “Media is stated by id, never by URL”A fixture’s job is to state what a saved page holds, and a saved page holds an
asset id — the platform turns that id into a served URL at render time. So a fixture
does the same, through the typed media constructors on this entry: give an image
slot media.photo(id), a video slot media.video(id), a tour slot
media.tour(id), and a document slot
media.document(id), and every resolved field — the url, the alt fallback, the
responsive ladder, a tour’s embed src and provider, a document’s filename,
content_type and size_bytes — is
attached for you when the fixture loads, in the preview server and in check alike.
The id unions come off the @homepages/dev-media
catalog, so your editor autocompletes every asset that exists and an id the pack
does not ship is a compile error; the image and document catalogs are disjoint, so
media.photo cannot name a PDF and media.document cannot name a photo. The
resolved-value fields (url, alt, poster, provider, filename,
content_type, size_bytes) cannot be stated in a fixture at all — a fixture that
states a URL would be exercising a path real content never takes.
A video slot has one spelling per remaining semantic: media.video.link(url, provider?, { poster? }) states a provider embed — its url IS load-bearing, the finished embed src a
resolved link value carries, and its optional poster names a pack image standing in
for the provider thumbnail production derives, resolved the way a photo is — and
media.video.none states the deliberately-unfilled branch, resolving to the same empty
value an unfilled slot holds at runtime.
A tour has ONE spelling where a video has three, because a tour is always a
provider link: there is no hosted branch to distinguish, and the poster-present and
poster-less states are two pool MEMBERS rather than two ref shapes — which is what
makes them pickable in the editor as well as statable here. media.tour.none covers
the deliberately-unfilled slot. Its ids come from a pool of their own, DEV_TOURS
on this entry (with getDevTour to read one and the DevTour / TourAssetId types
beside them), not from the media pack: a tour ships no bytes — the provider
hosts the model — so there is nothing for a pack to carry, and what a tour needs is
an embed src, a provider and, when the provider publishes one, a poster. Every pooled
entry is a real, publicly published tour, so its embed loads in the dev host. That
poster is an ordinary pack image — the thumbnail the provider itself publishes for
the model, imported into the pack — so it resolves with the same responsive ladder a
photo gets, and a tour whose provider publishes none (the pool’s Biganto entry)
resolves poster-less, which is that provider’s steady state rather than a pending one.
The ids to use are the scenario’s own: media.photo(s.photos[n].id) for a photo,
media.photo(primaryContact(s).fields.headshot_id) (or .logo_light_id /
.logo_dark_id) for a contact’s images, media.video(v.id) for a hosted video picked
out of the scenario’s video pool (s.media.videos — narrow to v.source === "upload",
which is also what makes v.id an asset id rather than a pasted link), and
media.document(s.documents[n].id) for an attached file — a whole document list maps
the array: scenarios.luxuryMultiUnit.documents.map((d) => media.document(d.id)).
Documents are optional collateral rather than something every listing has, so
s.documents is empty in every scenario but luxuryMultiUnit — that empty array is
the branch a states entry previews a document-less listing against. Naming a catalog
id directly is equally safe — an id that names nothing does not compile. A tour is the
one kind whose id does not come off the scenario: s.media.tour_3d_url is a pasted
URL, the value a url({ accepts: "tour" }) slot holds, while a tour SLOT names a
DEV_TOURS id — pick one from that pool directly. Floor plan refs
(s.facts.units[*].floor_plans) likewise carry only id/floor_label/position;
their url, alt, and responsive are optional and appear only after resolution.
A POI’s photo rides on the row
Section titled “A POI’s photo rides on the row”A POI row carries its own image, and it is not an image slot: it is a photo
object nested on the row, holding width, height, an attribution credit, and —
in a fixture — a photo_id. Resolution swaps that id for the url and responsive
ladder a real row arrives with. poisByCategory hands back finished rows, photo
included, so a fixture names which category feeds which slot and states nothing
about the row shape:
import { poisByCategory, scenarios } from "@homepages/template-kit/fixtures";
const g = poisByCategory(scenarios.luxuryMultiUnit);
const slots = { restaurant_pois: g.restaurant, park_pois: g.park,};photo is optional and often absent, because a place-data source genuinely has
no image for many places — bus stops and small parks especially. The corpus mirrors
that: most POIs in luxuryMultiUnit and forRentCondo carry one, a handful in each
carry none (every culture row among them), and sparseSingleFamily carries none at
all. Preview against both, since a row without a photo is a layout your section has
to handle, not an error.
What displaying the image obliges you to render alongside it, and why a row that
has a photo can still arrive without a url, are properties of the row rather than
of the fixture: schema § POI slots.
See Pick a scenario for the full picker and helper cheat-sheet.
See also
Section titled “See also”- Pick a scenario — the full scenario picker and helper cheat-sheet.
- Preview a section’s states — adding a
statesentry for a structural edge case. fixtures-invalid— whatcheckrejects a fixtures declaration for.