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.
Section — the section root
Section titled “Section — the section root”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 — the editable-slot boundary
Section titled “Slot — the editable-slot boundary”<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.
SlotGroup — slots edited as one unit
Section titled “SlotGroup — slots edited as one unit”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.
Image — the defensive image primitive
Section titled “Image — the defensive image primitive”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.
Video — the hosted-video primitive
Section titled “Video — the hosted-video primitive”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 marker contract
Section titled “The marker contract”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 stay pure
Section titled “Renderers stay pure”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.