Skip to content
HomePagesHomePages template kit
Menu

Contract primitives

The six contract primitives a section renderer must use, and the marker attributes they emit.

Everything here is imported from the package root:

import { Image, Section, Slot, SlotGroup, SlotItem, Video } from "@homepages/template-kit";

The kit ships six components, and nothing else. That is deliberate: a primitive earns its place only by emitting something the platform breaks without — the section box model, the editor’s marker attributes, the responsive-image machinery, the poster/playback contract for hosted video. Presentation is yours. Headings, links, layout wells, icons, lists, cards: write them as JSX in your section, or reach for a package.

Primitive Emits Required when
Section class="tr-section" on the section root always — every renderer roots at exactly one
Slot data-slot-id (+ optional data-slot-text-leaf) around every editable slot’s outer DOM
SlotItem data-slot-item around each item of an object_list / image_collection / video_collection slot
SlotGroup data-slot-group around the members of a group declared in meta.groups
Image <picture> / srcset / the empty-state box every image — sections never write a raw <img>
Video the poster frame / the playback config / the empty-state box every video — sections never write a raw <video>

A slot with no marker in the DOM cannot be selected or edited. The markers are the only thing connecting the pixels a user clicks to the schema key behind them.

export default function Hero({ slots, nav }: Props) {
return (
<Section id={nav.selfAnchor} className="bg-surface py-16">
</Section>
);
}

Renders <section class="tr-section"> and merges any className you pass. as picks a different element from section | div | article | header | footer | aside.

.tr-section (from base.css) is full-bleed by contract: 100% width, zero padding, zero margin, so consecutive sections butt edge-to-edge and a page composes as a vertical stack with no framework-injected whitespace. Vertical separation is the section’s own job — a background and padding on this root or a child, never a margin that would re-open gaps between sections.

A centered, max-width well is plain JSX on a child:

<div className="w-full max-w-[var(--tr-container-max)] mx-auto px-[var(--tr-container-pad)]">
<Slot id="headline" as="h1" textLeaf className="text-4xl">
<span>{slots.headline}</span>
</Slot>
Prop Meaning
id The slot key from schema.ts, written as a string literal. Emitted as data-slot-id.
as Element to render. Default "div".
textLeaf For a single-leaf text slot: marks the immediate child element with data-slot-text-leaf.
…rest Any HTML attribute — className, style, ARIA, data-*.

textLeaf is what lets the editor write textContent on the leaf on every keystroke (the optimistic path). It only handles the single element child case: with raw text, a fragment, or several children it is a silent no-op — the outer data-slot-id still lands, the editor just skips the optimistic update and converges on the next render. A multi-leaf slot (an address with one leaf per line) marks its leaves itself.

Slot still emits its marker when it has no children. That is what keeps an unfilled slot selectable, so a user can click the empty shell and type into it.

Not every slot needs a <Slot> wrapper: a primitive with a natural single root for its slot type takes a slotId prop and emits the marker itself — <Image slotId="hero_image"> needs no wrapper. <Slot> is for everything else.

SlotItem — the per-item handle in a collection

Section titled “SlotItem — the per-item handle in a collection”

A collection slot (object_list, image_collection, video_collection) needs both markers: <Slot> marks the whole array, <SlotItem> marks one row of it.

<Slot id="amenities">
{slots.amenities.map((item, i) => (
<SlotItem key={item.id} index={i} as="li" className="border-b">
<h3>{item.label}</h3>
</SlotItem>
))}
</Slot>

The editor resolves the slot from the ancestor data-slot-id, then the specific item from the nearest data-slot-item. Without SlotItem a collection is editable only as a whole — no per-item selection, no per-item image cropping, no per-item live surfaces.

index must be the item’s zero-based position in the slot’s array as rendered. The editor writes edits back by that index, so an index that doesn’t match its position edits the wrong row. index is an array position, not a schema key, so unlike Slot’s and SlotGroup’s id it is exempt from the template-kit/slot-marker-literal lint rule.

Grouping is authored in two places that must agree.

schema.ts declares the group in meta.groups, which collapses its members into one card in the editor sidebar:

meta: {
displayName: "Contact",
groups: [
{ id: "agent", label: "Agent", members: ["agent_name", ["agent_email", "agent_phone"]] },
],
},

Renderer.tsx wraps the members’ shared container, which makes the canvas outline and select them as the single unit matching that one card:

<SlotGroup id="agent" className="flex flex-col gap-2">
<Slot id="agent_name" textLeaf><h3>{slots.agent_name}</h3></Slot>
<Slot id="agent_email" textLeaf><span>{slots.agent_email}</span></Slot>
<Slot id="agent_phone" textLeaf><span>{slots.agent_phone}</span></Slot>
</SlotGroup>

The primitive’s id must equal the group’s id in meta.groups. SlotGroup must be an ancestor of every member’s data-slot-id element — the editor walks up with closest("[data-slot-group]"), so a marker that isn’t an ancestor is never found.

Sections never write a raw <img>. Image owns the responsive <picture>, the empty-state box, and the layout box that survives a missing source.

<Image
slotId="hero_image"
src={slots.hero_image.url}
alt={slots.hero_image.alt}
responsive={slots.hero_image.responsive}
sizes="(min-width: 1024px) 1200px, 100vw"
aspectRatio="5 / 3"
/>
Prop Meaning
src Image URL. Required prop; null/empty is a supported state, not a bug.
alt Required. Empty string for a decorative image.
slotId Emits data-slot-id on the wrapper — an image slot needs no <Slot> around it.
aspectRatio CSS aspect-ratio reserved on the wrapper. Survives a missing src.
responsive The platform’s ResponsiveImage descriptor (ImageValue.responsive).
responsiveMobile Mobile-crop descriptor (ImageValue.mobile.responsive); needs mobileBreakpoint.
mobileBreakpoint Desktop↔mobile switch width in px. The mobile <source> gets max-width: breakpoint − 1.
sizes The slot’s sizes attribute, applied to every srcset candidate.
maxRungWidth Per-section srcset ceiling in px. Wider rungs are dropped (the smallest is always kept).
bare Escape hatch: a single <img>, no wrapper.

Framed (the default). A wrapper div carries the slot marker, holds the aspect-ratio box, and absolutely-positions the inner <img> with object-cover. Cropping happens upstream — the platform delivers a pre-cropped asset — so the image is simply centered. When src is empty the wrapper renders alone as a neutral role="img" rect, keeping the layout box and the slot selectable. That deterministic empty state is the reason the frame exists, so pass aspectRatio rather than sizing the image from its own content.

The frame’s placeholder fill rides on .tr-image-frame (from base.css), not on a theme utility: you name your own tokens, so no primitive may assume a colour of yours exists. Retint it per template by setting --tr-image-frame-bg; it defaults to a neutral grey.

Responsive. Given a responsive descriptor the <img> is wrapped in <picture> with per-format AVIF/WebP <source srcset> (the base raster stays on the <img>), plus a media-gated mobile crop when responsiveMobile + mobileBreakpoint are set, and carries sizes and intrinsic width/height. With no descriptor it degrades to a plain single <img src> — no srcset, no <picture>.

Bare. bare renders one <img class="w-full"> with no wrapper, for chrome images whose parent already establishes the layout box (a logo you want object-contain, a slide inside its own aspect-ratio parent). It deliberately sets no object-fit, so an object-contain you pass isn’t fighting a built-in object-cover. It forfeits the empty-state box — reach for it only when the parent guarantees the box.

Sections never write a raw <video>. Video owns the poster frame, the author-declared playback config, and the same layout box that survives a missing source.

<Video
slotId="tour_video"
src={slots.tour_video.url}
poster={slots.tour_video.poster}
alt={slots.tour_video.alt}
playback={schema.slots.tour_video.playback}
aspectRatio="16 / 9"
/>
Prop Meaning
src The rendition URL. Required prop; null/empty is a supported state, not a bug.
poster Poster-frame URL. The first paint, and the entire render while src is empty.
alt Required. Empty string for a purely decorative loop.
slotId Emits data-slot-id on the wrapper — a video slot needs no <Slot> around it.
aspectRatio CSS aspect-ratio reserved on the wrapper. Survives a missing src.
playback The slot’s playback config. Absent ⇒ a plain controls player.
width / height Intrinsic rendition dimensions (VideoValue.width/height) — reserve the box before metadata loads.

Framed, always. There is no bare escape hatch: a wrapper div carries the slot marker and the aspect-ratio box, and absolutely-positions the inner <video> with object-cover. When src is empty the wrapper renders the poster alone — or a neutral role="img" rect if there is no poster — keeping the layout box and the slot selectable. Both empty states are routine: an unfilled video slot is a thin listing, and a poster with no rendition is what a video still transcoding looks like.

The placeholder fill rides on the same .tr-image-frame class <Image> uses (from base.css) — one neutral rect, one --tr-image-frame-bg token, so a template retints both frames once.

Playback is passed through verbatim — the primitive infers nothing from the asset. The config is authored on the slot, not chosen here; see Video slots for the fields and the autoplay-requires-muted rule.

There is no cropping and no responsive ladder: one progressive MP4 is served per video, and the poster is derived upstream.

The primitives are the sanctioned way to produce these attributes. The constants are exported so a section’s own browser-side code can query the DOM without hardcoding a magic string.

Constant Attribute On
ATTR_SLOT_ID data-slot-id the Slot wrapper / Image’s and Video’s framed wrapper
ATTR_SLOT_TEXT_LEAF data-slot-text-leaf the text leaf inside a text slot
ATTR_SLOT_ITEM data-slot-item one item of a collection slot
ATTR_SLOT_GROUP data-slot-group the container wrapping a group’s members
ATTR_SECTION_INSTANCE_ID data-section-instance-id the section root — written by the platform, not by a primitive

Changing what an attribute means is a platform contract break, so these names are stable across patch releases of the kit; a rename is exactly the kind of breaking change that ships as a minor bump (see Upgrading the kit).

Renderers are pure functions of props: no fetches, no globals, no useEffect for data, no Date.now() / Math.random(). They are rendered to static markup by the publisher and re-mounted constantly by the editor. Interactivity that needs the browser moves into a "use client" island (see Islands), never into render-time side effects.