Skip to content
HomePagesHomePages template kit

Pick a fixture scenario

Source a fixture's slots from real property data, via defineFixtures or by the fill-by-source rule.

Fill a section’s fixtures.ts from the kit’s golden scenarios instead of inventing values, so preview stays honest and every fixture is grounded in a real, self-consistent property.

  • A section with a fixtures.ts to fill — Install scaffolds one.

scenario(key, authoredSlots) — the entry point for defineFixtures — takes a ScenarioKey directly, so an unknown key is a compile error:

export const fixtures = defineFixtures(
schema,
(scenario) => ({ base: scenario("luxuryMultiUnit", { /* authored slots only */ }) }),
);

The export name is part of the contract: check and the preview server load fixtures.ts’s export const fixtures binding by name. There is nothing else to pass: defineFixtures takes the schema and the build callback, and reads the fill decisions off the schema itself — every decision rides on the slot it fills, so there is no second declaration for a fixture to be handed.

base almost always builds on luxuryMultiUnit — it’s the breadth ceiling, so a layout that survives it survives everything smaller. Reach for sparseSingleFamily or forRentCondo for a states entry designed to look different against a thinner property — pass a different key to that state’s own scenario() call, since a defineFixtures state resolves independently and never inherits base’s scenario.

Scenario units listing_intent property_type contacts pois poi photos photos contact logos media documents
luxuryMultiUnit 12 for_sale multi_family 3 24 18 of 24 12 both logos 3 videos (1 upload, 2 links) + 3D tour 3
sparseSingleFamily 1 for_sale single_family 1 4 none 3 (reused from forRentCondo) none (null) empty video pool, no 3D tour none
forRentCondo 1 for_rent condo_townhome 2 8 5 of 8 8 both logos 1 video (upload), no 3D tour none

poi photos counts the rows whose photo is stated — a POI’s image rides on the row rather than in a slot, and it is absent on many places for real (how to state one). The scenarios that carry them keep some rows bare on purpose, so a section is exercised against both; sparseSingleFamily is the thin-property case and carries none.

documents counts s.documents, the listing’s attached files — a brochure, a disclosure pack. They are optional collateral rather than something every listing has, so only luxuryMultiUnit carries any and the other two are the empty branch a document slot has to render.

Reading a scenario’s raw data directly (rather than through scenario()) is still useful — for the helper cheat-sheet below, and for the values an .override(…) states when a scenario cannot produce them. Import scenarios from the same fixtures entry — never the root barrel; dev-only sample data must never reach a published renderer bundle’s import graph:

import { scenarios } from "@homepages/template-kit/fixtures";
const luxury = scenarios.luxuryMultiUnit;
const sparse = scenarios.sparseSingleFamily;
const rent = scenarios.forRentCondo;

Fill an authored slot by its fill-spec source

Section titled “Fill an authored slot by its fill-spec source”

defineFixtures resolves a fact-bound or compute()-bound slot on its own — you only author what’s left: a plain content slot, or one behind a fill decision. scenario()’s authored-slots argument subtracts every source-bound key from the section’s schema, so writing one there is a compile error rather than a value that quietly loses. Don’t invent a value for the rest either — ground them in the same scenario:

Slot’s fill-spec source How the fixture fills it
a fact (fact.<container>.<field>) resolved automatically by defineFixtures; read straight off the scenario (s.facts.address.display, primaryContact(s).fields.phone) only to state an .override(…)
compute() resolved automatically by defineFixtures; a helper (propertyMetrics(s), poisByCategory(s), contactCards(s)) if you need the value itself
a fill decision a short sample fill grounded in the scenario’s facts — a headline about this property, never lorem ipsum or invented facts — authored either way

A fixture that gets this wrong is worse than a missing one: a slot bound fact.unit.beds given an authored value that doesn’t match s.facts.units[0].beds teaches nothing about the layout it’s supposed to preview — one more reason defineFixtures is the shape to reach for: a fact-bound slot cannot drift from the scenario, because nothing you write can override it short of .override().

Every helper is a named export of the fixtures subpath, so reaching for one means adding it to the fixture file’s existing import — the scaffolded file takes only defineFixtures and scenario. scenarios and propertyMetrics are the two the worked example below uses:

import {
contactCards,
poisByCategory,
primaryContact,
propertyMetrics,
scenarios,
} from "@homepages/template-kit/fixtures";
const s = scenarios.luxuryMultiUnit;
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 bedsMin/bedsMax/bathsMin/bathsMax/sqftMax/priceStart across all units

propertyMetrics returns kit fact vocabulary — bedsMin, priceStart and siblings, not 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, off a const m = propertyMetrics(s); in the same module:

// fixtures.ts — in the base scenario's authored slots
beds_min: m.bedsMin,
price_start: m.priceStart,

Fields the helpers don’t cover are readable straight off the scenario: s.photos, s.pois, s.amenities, s.contacts, s.media, s.documents, and s.facts (the raw PropertyFacts). 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.

Every media reference in a scenario is an asset id, the same thing a saved page holds — s.photos[n].id, primaryContact(s).fields.headshot_id / .logo_light_id / .logo_dark_id, an upload row’s id in s.media.videos, s.documents[n].id, and a floor plan ref’s id. Put the id in the fixture through the typed constructors — media.photo(id) for any image slot, media.video(id) for a hosted video, media.document(id) for a document slot (a document list maps the array: s.documents.map((d) => media.document(d.id))) — and stop there: the url, the alt fallback, the responsive ladder, and a document’s filename, content_type and size_bytes are attached for you when the fixture loads. A url is never statable in a fixture; media.video.link(url, provider?, { poster? }) is the one deliberate exception, because a provider embed’s url IS the saved value (its poster, though, is still a pack image id, never a URL). (s.media.tour_3d_url stays a plain URL too — a 3D tour is a third-party embed with no asset behind it.)

s.media.videos is the whole candidate pool, not one selection, and it mixes both kinds: an upload row names a pack asset, a link row states the url an agent pasted. Branch on source before reading a row — only an upload row has an id media.video() accepts, and only a link row has a url.

A states entry designed to look different against a sparser property sources from a different scenario, not from overridden literals — see Preview edge cases for the full pattern.

The defineFixtures call under Steps is the whole change — nothing outside fixtures.ts moves.

Verify with the author loop.

  • fixtures-invalid — the export binds a plain object rather than the module defineFixtures returns, or that module is malformed.
  • section-unrenderable — the scenario you picked cannot supply a fact one of the section’s slots needs, so fixtures.ts throws while it loads.