@homepages/template-kit 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +182 -0
  2. package/README.md +71 -14
  3. package/dist/base.css +85 -0
  4. package/dist/browser.d.ts +26 -0
  5. package/dist/browser.js +55 -0
  6. package/dist/cli.js +1 -1
  7. package/dist/contracts/image-descriptor.d.ts +17 -0
  8. package/dist/contracts/markers.d.ts +20 -0
  9. package/dist/contracts/markers.js +21 -0
  10. package/dist/contracts/slot-catalog.d.ts +237 -0
  11. package/dist/contracts/slot-catalog.js +247 -0
  12. package/dist/contracts/transforms.d.ts +108 -0
  13. package/dist/contracts/transforms.js +117 -0
  14. package/dist/design-system/breakpoints.d.ts +5 -0
  15. package/dist/design-system/breakpoints.js +14 -0
  16. package/dist/design-system/theme.d.ts +307 -0
  17. package/dist/design-system/theme.js +95 -0
  18. package/dist/eslint/is-client-file.js +79 -0
  19. package/dist/eslint/rules/image-bare-needs-reason.js +38 -0
  20. package/dist/eslint/rules/no-client-directive-in-contract.js +26 -0
  21. package/dist/eslint/rules/no-client-runtime-in-server.js +99 -0
  22. package/dist/eslint/rules/no-css-import-from-render-path.js +23 -0
  23. package/dist/eslint/rules/no-hex.js +111 -0
  24. package/dist/eslint/rules/no-inline-style.js +23 -0
  25. package/dist/eslint/rules/no-nondeterminism.js +44 -0
  26. package/dist/eslint/rules/no-raw-element.js +32 -0
  27. package/dist/eslint/rules/props-from-schema.js +32 -0
  28. package/dist/eslint/rules/serializable-island-props.js +108 -0
  29. package/dist/eslint/rules/slot-marker-literal.js +33 -0
  30. package/dist/eslint.d.ts +4 -8
  31. package/dist/eslint.js +75 -11
  32. package/dist/fixtures/sample-property.d.ts +67 -0
  33. package/dist/fixtures/sample-property.js +707 -0
  34. package/dist/fixtures.d.ts +2 -0
  35. package/dist/fixtures.js +3 -0
  36. package/dist/index.d.ts +37 -13
  37. package/dist/index.js +34 -2
  38. package/dist/island-runtime.d.ts +30 -0
  39. package/dist/island-runtime.js +73 -0
  40. package/dist/islands/contract.d.ts +56 -0
  41. package/dist/islands/contract.js +71 -0
  42. package/dist/islands/marker.d.ts +16 -0
  43. package/dist/islands/marker.js +18 -0
  44. package/dist/package.js +5 -0
  45. package/dist/primitives/Image.d.ts +49 -0
  46. package/dist/primitives/Image.js +95 -0
  47. package/dist/primitives/Section.d.ts +22 -0
  48. package/dist/primitives/Section.js +26 -0
  49. package/dist/primitives/Slot.d.ts +50 -0
  50. package/dist/primitives/Slot.js +39 -0
  51. package/dist/primitives/SlotGroup.d.ts +38 -0
  52. package/dist/primitives/SlotGroup.js +24 -0
  53. package/dist/primitives/SlotItem.d.ts +43 -0
  54. package/dist/primitives/SlotItem.js +38 -0
  55. package/dist/primitives/index.d.ts +7 -0
  56. package/dist/primitives/picture-sources.d.ts +22 -0
  57. package/dist/primitives/picture-sources.js +52 -0
  58. package/dist/schema/fill-spec.d.ts +692 -0
  59. package/dist/schema/fill-spec.js +98 -0
  60. package/dist/schema/fixture-schema.d.ts +143 -0
  61. package/dist/schema/fixture-schema.js +113 -0
  62. package/dist/schema/index.d.ts +16 -0
  63. package/dist/schema/manifest.d.ts +527 -0
  64. package/dist/schema/manifest.js +73 -0
  65. package/dist/schema/property-facts.d.ts +44 -0
  66. package/dist/schema/resolve-variants.d.ts +11 -0
  67. package/dist/schema/resolve-variants.js +15 -0
  68. package/dist/schema/runtime-values.d.ts +45 -0
  69. package/dist/schema/section-nav.d.ts +7 -0
  70. package/dist/schema/section-props.d.ts +69 -0
  71. package/dist/schema/section-schema.d.ts +533 -0
  72. package/dist/schema/section-schema.js +72 -0
  73. package/dist/schema/slot-types.d.ts +117 -0
  74. package/dist/schema/slot-types.js +181 -0
  75. package/dist/schema/source.d.ts +13 -0
  76. package/dist/schema/source.js +12 -0
  77. package/dist/schema/synthesize-nullable.d.ts +18 -0
  78. package/dist/schema/synthesize-nullable.js +47 -0
  79. package/dist/styles.css +131 -9
  80. package/docs/INDEX.md +6 -4
  81. package/docs/eslint.md +90 -0
  82. package/docs/islands.md +150 -0
  83. package/docs/llms.txt +30 -3
  84. package/docs/primitives.md +214 -0
  85. package/docs/schema-system.md +240 -0
  86. package/docs/theme-and-css.md +179 -0
  87. package/package.json +30 -6
  88. package/tsconfig.base.json +16 -0
  89. package/tsconfig.json +2 -13
  90. package/dist/src-CrdHXijV.js +0 -23
package/docs/eslint.md ADDED
@@ -0,0 +1,90 @@
1
+ ---
2
+ purpose: The authoring-lint preset shipped at `@homepages/template-kit/eslint` — setup, scope, and the eleven rule ids.
3
+ status: living
4
+ related: [INDEX.md, islands.md, theme-and-css.md, primitives.md]
5
+ updated: 2026-07-14
6
+ ---
7
+ # ESLint preset
8
+
9
+ `@homepages/template-kit/eslint` is a flat-config ESLint preset carrying every
10
+ authoring rule the kit enforces one file at a time, under the `template-kit/*`
11
+ rule namespace.
12
+
13
+ ## Setup
14
+
15
+ ```js
16
+ // eslint.config.mjs
17
+ import kit from "@homepages/template-kit/eslint";
18
+ export default kit;
19
+ ```
20
+
21
+ That is the whole setup — no plugin registration, no parser wiring, no tsconfig.
22
+ The preset ships its own parser (`@typescript-eslint/parser`, a real `dependency`
23
+ of the kit, not a devDependency) and sets `languageOptions` itself, so `npm i -D
24
+ eslint` plus the three lines above is a complete consumer config.
25
+
26
+ The preset is **syntactic only** — it never sets `parserOptions.project` or
27
+ `projectService`. Nothing it enforces requires type information, which is what
28
+ keeps setup to zero extra config; a consumer's own `tsconfig.json` is never
29
+ consulted.
30
+
31
+ ## Scope
32
+
33
+ The preset is consumed **once at a workspace root** and lints every section
34
+ nested under `templates/**`; there are no per-template config files. It ships
35
+ three config entries, matched by glob rather than by directory depth:
36
+
37
+ | Entry | Files | Rules |
38
+ |---|---|---|
39
+ | `template-kit/authoring` | `**/*.ts`, `**/*.tsx` | the nine file-scoped rules below |
40
+ | `template-kit/contract-files` | `**/Renderer.tsx`, `**/schema.ts`, `**/fill-spec.ts`, `**/fixtures.ts` | `no-client-directive-in-contract` |
41
+ | `template-kit/renderer` | `**/Renderer.tsx` | `props-from-schema` |
42
+
43
+ Server-vs-client scoping is decided **per file**, by the `"use client"` directive
44
+ prologue — not by an import graph or a build transform. A file carrying the
45
+ directive is an island: it hydrates in the browser, so effects, state, event
46
+ handlers, and inline `style` are legal there and the corresponding rules exempt
47
+ it. A rule that does not exempt islands says so explicitly below.
48
+
49
+ ## The rules
50
+
51
+ Rules that are properties of one file live here. Rules that are properties of the
52
+ **tree** — a missing contract file, a slot declared in `schema.ts` but unmarked in
53
+ `Renderer.tsx`, a CSS rule — cannot be expressed one-file-at-a-time; they belong to
54
+ `template-kit check` (not yet built) under the same `template-kit/<id>` namespace.
55
+ One namespace, two venues.
56
+
57
+ ### Server-rendered code
58
+
59
+ | Rule | What it bans |
60
+ |---|---|
61
+ | `no-nondeterminism` | Clock/randomness reads (`Date.now()`, `new Date()` with no args, `Math.random()`, `crypto.randomUUID()`/`getRandomValues()`, `performance.now()`) — the publisher and the editor must render byte-identical HTML. Exempt inside an island. |
62
+ | `no-client-runtime-in-server` | Effects, state hooks, event handlers, and browser globals (`fetch`, `window`, `document`, …) — a Renderer must be a pure function of its props. Exempt inside an island. |
63
+ | `no-inline-style` | The `style=` JSX prop — it bypasses the cascade and Tailwind's responsive prefixes. Exempt inside an island. |
64
+ | `no-raw-element` | Raw `<section>`/`<img>` — each has a contract-bearing primitive (`Section`/`Image`) that must be used instead. |
65
+ | `image-bare-needs-reason` | `<Image bare>` with no `// bare: <reason>` comment justifying the skipped frame. |
66
+ | `no-css-import-from-render-path` | A `.css` import anywhere in a section's render path — the SSR graph drops it silently. Import CSS from the section's own `styles.css` instead. |
67
+
68
+ ### Islands
69
+
70
+ | Rule | What it checks |
71
+ |---|---|
72
+ | `serializable-island-props` | A statically-visible prop passed to an island (a literal function, `new Date()`/`Map`/`Set`/etc., a bigint literal, `NaN`/`Infinity`) that cannot cross the server→browser JSON boundary. It is the static twin of the runtime check — see [islands.md](islands.md#props-must-be-json-serializable). |
73
+
74
+ ### Universal — no island exemption
75
+
76
+ | Rule | What it bans |
77
+ |---|---|
78
+ | `no-hex` | Hex color literals and CSS color functions (`oklch`/`oklab`/`rgb`/`rgba`/`hsl`/`hsla`/`color-mix`) in a color context (a Tailwind color-utility bracket, an arbitrary-property color declaration, or an inline `style` value). Token discipline is universal — see [theme-and-css.md](theme-and-css.md#color-roles). |
79
+
80
+ Deliberate asymmetry: `no-inline-style` exempts islands, `no-hex` does not — the
81
+ hydration boundary is not a palette boundary. A section carries no palette of its
82
+ own in either render path.
83
+
84
+ ### Markers and schema
85
+
86
+ | Rule | What it checks |
87
+ |---|---|
88
+ | `slot-marker-literal` | A slot-key-bearing marker (`<Slot id>`, `<SlotGroup id>`, `slotId`, `data-slot-id`, `data-slot-group`) must carry a string-literal key, not an expression — the slot-marker completeness check (`template-kit check`) reads these keys statically. `SlotItem` is exempt: its prop is `index: number`, an array position, so there is no schema key to cross-check. |
89
+ | `props-from-schema` | A `Renderer.tsx`'s exported `Props` type must come from `SectionProps<typeof schema>`. A hand-written `Props` shadows that inference and drifts. |
90
+ | `no-client-directive-in-contract` | `"use client"` in a section's four contract files (`Renderer.tsx`, `schema.ts`, `fill-spec.ts`, `fixtures.ts`) — the platform reads them outside a browser; marking one an island takes the section's server-rendered HTML with it. |
@@ -0,0 +1,150 @@
1
+ ---
2
+ purpose: How interactivity works in a template — client components ("islands"), their props contract, and their editor options.
3
+ status: living
4
+ related: [INDEX.md, eslint.md]
5
+ updated: 2026-07-14
6
+ ---
7
+
8
+ # Islands: interactivity in a template
9
+
10
+ A section's `Renderer.tsx` is server-rendered and ships no JavaScript. When part of
11
+ a section needs to be interactive — a filter, a slider, a lightbox — you write an
12
+ ordinary React component in the section folder and mark it `"use client"`. It is
13
+ server-rendered with the rest of the page, then **hydrated** in the browser as its
14
+ own React root. That component is an *island*.
15
+
16
+ If a page contains no islands, it loads no React and no island code — every page
17
+ still ships the small island loader (a few KB, gzipped), but that loader only ever
18
+ reaches React through a dynamic import it takes when an island is actually on the page.
19
+
20
+ ## Writing one
21
+
22
+ ```tsx
23
+ // sections/floorplan/UnitFilter.tsx
24
+ "use client";
25
+
26
+ import { useState } from "react";
27
+
28
+ export default function UnitFilter({ units }: { units: { id: string; beds: number }[] }) {
29
+ const [beds, setBeds] = useState<number | null>(null);
30
+ const shown = beds === null ? units : units.filter((u) => u.beds === beds);
31
+ return ( /* … */ );
32
+ }
33
+ ```
34
+
35
+ Use it from the Renderer like any other component. The build wraps it in an island
36
+ marker and hydrates it on the published page:
37
+
38
+ ```tsx
39
+ import UnitFilter from "./UnitFilter.js";
40
+
41
+ export function Renderer({ units }: Props) {
42
+ return <UnitFilter units={units} />;
43
+ }
44
+ ```
45
+
46
+ Rules:
47
+
48
+ - **The component is default-exported.** One island per `"use client"` file.
49
+ - **Effects, state, event handlers, and browser APIs are all fine** inside an island —
50
+ it runs in the browser. They are *not* fine in a Renderer, which must stay a pure
51
+ function of its props.
52
+ - **Third-party client libraries are fine** inside an island (a slider, a map SDK).
53
+
54
+ ## Props must be JSON-serializable
55
+
56
+ Everything the Renderer passes to an island crosses a server→browser boundary as JSON:
57
+
58
+ | Allowed | Rejected |
59
+ |---|---|
60
+ | `string`, `number` (finite), `boolean`, `null` | functions, including event handlers |
61
+ | plain objects, arrays, nested combinations of the above | `Date`, `Map`, `Set`, `BigInt`, symbols, class instances |
62
+ | `undefined` values, per JSON: an object property is dropped, an array entry becomes `null` | `NaN`, `Infinity` |
63
+
64
+ A rejected prop fails the render, naming the path:
65
+
66
+ ```
67
+ IslandPropsError: props.units[0].created is a Date — island props must be
68
+ JSON-serializable (plain objects and arrays only)
69
+ ```
70
+
71
+ Pass an ISO string and parse it inside the island. Pass data, not callbacks — an
72
+ island owns its own handlers.
73
+
74
+ The `template-kit/serializable-island-props` [lint rule](eslint.md#islands) catches a
75
+ statically-visible violation (a literal arrow function, `new Date()`, a bigint literal,
76
+ `NaN`/`Infinity`) in your editor, before the render ever throws — a value assembled at
77
+ runtime and handed over as an identifier is invisible to the rule and stays this
78
+ runtime check's job.
79
+
80
+ ## Editor options
81
+
82
+ Templates are edited in a canvas where the section is re-rendered on every keystroke.
83
+ An island can declare how it behaves there:
84
+
85
+ ```tsx
86
+ "use client";
87
+ import type { IslandEditorOptions } from "@homepages/template-kit";
88
+
89
+ export default function Accordion({ items, singleOpen }: Props) { /* … */ }
90
+
91
+ export const editor: IslandEditorOptions = {
92
+ live: true, // hydrate in the editor canvas (default: false)
93
+ shieldMode: "fill-parent", // how the editor sizes its selection shield
94
+ liveSurface: (root) => [...root.querySelectorAll(".accordion")], // shield only these
95
+ props: { singleOpen: true }, // editor-only props, merged over the real ones
96
+ };
97
+ ```
98
+
99
+ Omit `editor` entirely and the island simply renders as static markup in the editor —
100
+ which is the right choice for most islands.
101
+
102
+ ## The DOM contract
103
+
104
+ Rarely needed while authoring, but this is what the build emits, and what a host
105
+ hydrates:
106
+
107
+ ```html
108
+ <tr-island style="display:contents" data-tr-island="<key>" data-tr-island-id="<id>">
109
+ …server-rendered output…
110
+ </tr-island>
111
+ <script type="application/json" data-tr-island-props="<id>">{"units":[…]}</script>
112
+ ```
113
+
114
+ The marker is layout-invisible (`display:contents`), so an island can sit directly
115
+ inside a grid or flex container without adding a box.
116
+
117
+ ## Hosting islands yourself
118
+
119
+ `@homepages/template-kit/island-runtime` is the loader. It is not something a template
120
+ author calls — it is what a page (or the editor) calls to bring islands to life:
121
+
122
+ ```ts
123
+ import { hydrateIslands, unmountIslands } from "@homepages/template-kit/island-runtime";
124
+
125
+ await hydrateIslands({ load: (key) => ISLANDS[key]() }); // ISLANDS: the build's island map
126
+ unmountIslands(sectionEl); // before replacing sectionEl's HTML
127
+ ```
128
+
129
+ Each island is an independent React root: one island's state, re-renders, and errors
130
+ never touch another's. Hydration is idempotent, so calling `hydrateIslands` again after
131
+ injecting new markup is safe.
132
+
133
+ ## Talking to HomePages from an island
134
+
135
+ `@homepages/template-kit/browser` carries the helpers a published page needs to reach the
136
+ HomePages ingest endpoints — reading the site's embedded config and signing requests:
137
+
138
+ ```ts
139
+ import { readSiteConfig, signedIngestHeaders } from "@homepages/template-kit/browser";
140
+
141
+ const config = readSiteConfig();
142
+ if (config) {
143
+ const body = JSON.stringify({ name, email });
144
+ await fetch(`${config.ingestBase}/leads/ingest`, {
145
+ method: "POST",
146
+ headers: await signedIngestHeaders(config, body),
147
+ body,
148
+ });
149
+ }
150
+ ```
package/docs/llms.txt CHANGED
@@ -4,10 +4,37 @@
4
4
  > inside the npm package so an agent can read it locally, versioned in lockstep
5
5
  > with the code it describes.
6
6
 
7
- Status: 0.0.x package skeleton. The authoring corpus (task recipes, rule-id
8
- reference, vocabulary) arrives with the surface it documents: the schema system,
9
- the contract primitives and theme tokens, and the `check` command's rule-ids.
7
+ Status: 0.0.x. The schema system, the contract primitives, and the theme tokens are
8
+ complete a section is authorable end to end against the kit. The `template-kit`
9
+ CLI's commands arrive with the surfaces they drive.
10
+
11
+ Everything an author writes a section against is imported from the package root —
12
+ `@homepages/template-kit`, no subpath imports. The two CSS entries
13
+ (`./styles.css`, `./base.css`) are the deliberate exception.
10
14
 
11
15
  ## Docs
12
16
 
17
+ - [Schema system](schema-system.md): the section authoring contract. A section is
18
+ four files — `schema.ts` (slots + options + `meta.groups`), `fill-spec.ts` (how AI
19
+ fills them), `fixtures.ts` (preview content, built on the kit's golden property
20
+ dataset), `Renderer.tsx` (markup, with props DERIVED from the schema via
21
+ `SectionProps<typeof schema>`). Covers the `as const satisfies` requirement, binding
22
+ slots to property facts with `direct()`/`derived()` over a closed vocabulary, and
23
+ fact-driven variants.
24
+ - [Contract primitives](primitives.md): the only five components the kit ships —
25
+ `Section` (full-bleed section root), `Slot` (`data-slot-id`), `SlotItem` (per-item
26
+ handle in an `object_list` / `image_collection`), `SlotGroup` (`data-slot-group`),
27
+ and `Image` (responsive `<picture>`, empty-state box). Plus the marker-attribute
28
+ contract (`ATTR_SLOT_ID`, `ATTR_SLOT_ITEM`, …). Presentation is the template's own JSX.
29
+ - [Theme and CSS](theme-and-css.md): the two CSS entries and how they load; import
30
+ Tailwind exactly once; `TokenTheme` + `compileThemeToCss`; the breakpoint ladder
31
+ (base 350, `bp390` `bp425` `bp512` `md` `lg` `bp1440` `bp1920`); the opt-in
32
+ `content-visibility` recipe for long, image-heavy pages, and why it requires a
33
+ full-screen overlay to be appended to `document.body` rather than rendered inside
34
+ the section.
13
35
  - [README](../README.md): install, exports, and the release flow.
36
+ - [Islands](islands.md): interactivity — client components, the serializable-props contract, editor options, and the browser loader.
37
+ - [ESLint preset](eslint.md): `@homepages/template-kit/eslint`, the zero-config flat-config
38
+ preset covering the eleven `template-kit/*` authoring rules — determinism, the
39
+ server/island boundary, serializable island props, the marker and schema contracts,
40
+ and token discipline.
@@ -0,0 +1,214 @@
1
+ ---
2
+ purpose: The five contract primitives a section renderer must use, and the marker attributes they emit.
3
+ status: living
4
+ related: [schema-system.md, theme-and-css.md, eslint.md]
5
+ updated: 2026-07-14
6
+ ---
7
+ # Contract primitives
8
+
9
+ Everything here is imported from the package root:
10
+
11
+ ```tsx
12
+ import { Image, Section, Slot, SlotGroup, SlotItem } from "@homepages/template-kit";
13
+ ```
14
+
15
+ The kit ships **five components, and nothing else**. That is deliberate: a primitive
16
+ earns its place only by emitting something the platform breaks without — the section
17
+ box model, the editor's marker attributes, the responsive-image machinery.
18
+ Presentation is yours. Headings, links, layout wells, icons, lists, cards: write them
19
+ as JSX in your section, or reach for a package.
20
+
21
+ | Primitive | Emits | Required when |
22
+ |---|---|---|
23
+ | `Section` | `class="tr-section"` on the section root | always — every renderer roots at exactly one |
24
+ | `Slot` | `data-slot-id` (+ optional `data-slot-text-leaf`) | around every editable slot's outer DOM |
25
+ | `SlotItem` | `data-slot-item` | around each item of an `object_list` / `image_collection` slot |
26
+ | `SlotGroup` | `data-slot-group` | around the members of a group declared in `meta.groups` |
27
+ | `Image` | `<picture>` / `srcset` / the empty-state box | every image — sections never write a raw `<img>` |
28
+
29
+ A slot with no marker in the DOM **cannot be selected or edited**. The markers are the
30
+ only thing connecting the pixels a user clicks to the schema key behind them.
31
+
32
+ ## `Section` — the section root
33
+
34
+ ```tsx
35
+ export default function Hero({ slots, nav }: Props) {
36
+ return (
37
+ <Section id={nav.selfAnchor} className="bg-surface py-16">
38
+
39
+ </Section>
40
+ );
41
+ }
42
+ ```
43
+
44
+ Renders `<section class="tr-section">` and merges any `className` you pass. `as` picks
45
+ a different element from `section | div | article | header | footer | aside`.
46
+
47
+ `.tr-section` (from [`base.css`](theme-and-css.md)) is **full-bleed by contract**: 100%
48
+ width, zero padding, zero margin, so consecutive sections butt edge-to-edge and a page
49
+ composes as a vertical stack with no framework-injected whitespace. Vertical separation
50
+ is the section's own job — a background and padding on this root or a child, never a
51
+ margin that would re-open gaps between sections.
52
+
53
+ A centered, max-width well is plain JSX on a child:
54
+
55
+ ```tsx
56
+ <div className="w-full max-w-[var(--tr-container-max)] mx-auto px-[var(--tr-container-pad)]">
57
+ ```
58
+
59
+ ## `Slot` — the editable-slot boundary
60
+
61
+ ```tsx
62
+ <Slot id="headline" as="h1" textLeaf className="text-4xl">
63
+ <span>{slots.headline}</span>
64
+ </Slot>
65
+ ```
66
+
67
+ | Prop | Meaning |
68
+ |---|---|
69
+ | `id` | The slot key from `schema.ts`, written as a string literal. Emitted as `data-slot-id`. |
70
+ | `as` | Element to render. Default `"div"`. |
71
+ | `textLeaf` | For a single-leaf `text` slot: marks the immediate child element with `data-slot-text-leaf`. |
72
+ | …rest | Any HTML attribute — `className`, `style`, ARIA, `data-*`. |
73
+
74
+ `textLeaf` is what lets the editor write `textContent` on the leaf on every keystroke
75
+ (the optimistic path). It only handles the **single element child** case: with raw text,
76
+ a fragment, or several children it is a silent no-op — the outer `data-slot-id` still
77
+ lands, the editor just skips the optimistic update and converges on the next render. A
78
+ multi-leaf slot (an address with one leaf per line) marks its leaves itself.
79
+
80
+ `Slot` still emits its marker when it has no children. That is what keeps an unfilled
81
+ slot selectable, so a user can click the empty shell and type into it.
82
+
83
+ Not every slot needs a `<Slot>` wrapper: a primitive with a natural single root for its
84
+ slot type takes a `slotId` prop and emits the marker itself — `<Image slotId="hero_image">`
85
+ needs no wrapper. `<Slot>` is for everything else.
86
+
87
+ ## `SlotItem` — the per-item handle in a collection
88
+
89
+ A collection slot (`object_list`, `image_collection`) needs **both** markers: `<Slot>`
90
+ marks the whole array, `<SlotItem>` marks one row of it.
91
+
92
+ ```tsx
93
+ <Slot id="amenities">
94
+ {slots.amenities.map((item, i) => (
95
+ <SlotItem key={item.id} index={i} as="li" className="border-b">
96
+ <h3>{item.label}</h3>
97
+ </SlotItem>
98
+ ))}
99
+ </Slot>
100
+ ```
101
+
102
+ The editor resolves the slot from the ancestor `data-slot-id`, then the specific item
103
+ from the nearest `data-slot-item`. Without `SlotItem` a collection is editable only as a
104
+ whole — no per-item selection, no per-item image cropping, no per-item live surfaces.
105
+
106
+ `index` **must** be the item's zero-based position in the slot's array as rendered. The
107
+ editor writes edits back by that index, so an index that doesn't match its position edits
108
+ the wrong row. `index` is an array position, not a schema key, so unlike `Slot`'s and
109
+ `SlotGroup`'s `id` it is exempt from the [`template-kit/slot-marker-literal`](eslint.md#markers-and-schema)
110
+ lint rule.
111
+
112
+ ## `SlotGroup` — slots edited as one unit
113
+
114
+ Grouping is authored in two places that must agree.
115
+
116
+ `schema.ts` declares the group in `meta.groups`, which collapses its members into one
117
+ card in the editor sidebar:
118
+
119
+ ```ts
120
+ meta: {
121
+ displayName: "Contact",
122
+ groups: [
123
+ { id: "agent", label: "Agent", members: ["agent_name", ["agent_email", "agent_phone"]] },
124
+ ],
125
+ },
126
+ ```
127
+
128
+ `Renderer.tsx` wraps the members' shared container, which makes the canvas outline and
129
+ select them as the single unit matching that one card:
130
+
131
+ ```tsx
132
+ <SlotGroup id="agent" className="flex flex-col gap-2">
133
+ <Slot id="agent_name" textLeaf><h3>{slots.agent_name}</h3></Slot>
134
+ <Slot id="agent_email" textLeaf><span>{slots.agent_email}</span></Slot>
135
+ <Slot id="agent_phone" textLeaf><span>{slots.agent_phone}</span></Slot>
136
+ </SlotGroup>
137
+ ```
138
+
139
+ The primitive's `id` **must equal** the group's `id` in `meta.groups`. `SlotGroup` must
140
+ be an **ancestor** of every member's `data-slot-id` element — the editor walks up with
141
+ `closest("[data-slot-group]")`, so a marker that isn't an ancestor is never found.
142
+
143
+ ## `Image` — the defensive image primitive
144
+
145
+ Sections never write a raw `<img>`. `Image` owns the responsive `<picture>`, the
146
+ empty-state box, and the layout box that survives a missing source.
147
+
148
+ ```tsx
149
+ <Image
150
+ slotId="hero_image"
151
+ src={slots.hero_image.url}
152
+ alt={slots.hero_image.alt}
153
+ responsive={slots.hero_image.responsive}
154
+ sizes="(min-width: 1024px) 1200px, 100vw"
155
+ aspectRatio="5 / 3"
156
+ />
157
+ ```
158
+
159
+ | Prop | Meaning |
160
+ |---|---|
161
+ | `src` | Image URL. Required prop; `null`/empty is a supported state, not a bug. |
162
+ | `alt` | Required. Empty string for a decorative image. |
163
+ | `slotId` | Emits `data-slot-id` on the wrapper — an image slot needs no `<Slot>` around it. |
164
+ | `aspectRatio` | CSS `aspect-ratio` reserved on the wrapper. Survives a missing `src`. |
165
+ | `responsive` | The platform's `ResponsiveImage` descriptor (`ImageValue.responsive`). |
166
+ | `responsiveMobile` | Mobile-crop descriptor (`ImageValue.mobile.responsive`); needs `mobileBreakpoint`. |
167
+ | `mobileBreakpoint` | Desktop↔mobile switch width in px. The mobile `<source>` gets `max-width: breakpoint − 1`. |
168
+ | `sizes` | The slot's `sizes` attribute, applied to every srcset candidate. |
169
+ | `maxRungWidth` | Per-section srcset ceiling in px. Wider rungs are dropped (the smallest is always kept). |
170
+ | `bare` | Escape hatch: a single `<img>`, no wrapper. |
171
+
172
+ **Framed (the default).** A wrapper `div` carries the slot marker, holds the aspect-ratio
173
+ box, and absolutely-positions the inner `<img>` with `object-cover`. Cropping happens
174
+ upstream — the platform delivers a pre-cropped asset — so the image is simply centered.
175
+ When `src` is empty the wrapper renders alone as a neutral `role="img"` rect, keeping the
176
+ layout box and the slot selectable. **That deterministic empty state is the reason the
177
+ frame exists**, so pass `aspectRatio` rather than sizing the image from its own content.
178
+
179
+ **Responsive.** Given a `responsive` descriptor the `<img>` is wrapped in `<picture>` with
180
+ per-format AVIF/WebP `<source srcset>` (the base raster stays on the `<img>`), plus a
181
+ media-gated mobile crop when `responsiveMobile` + `mobileBreakpoint` are set, and carries
182
+ `sizes` and intrinsic `width`/`height`. With no descriptor it degrades to a plain single
183
+ `<img src>` — no `srcset`, no `<picture>`.
184
+
185
+ **Bare.** `bare` renders one `<img class="w-full">` with no wrapper, for chrome images
186
+ whose parent already establishes the layout box (a logo you want `object-contain`, a slide
187
+ inside its own aspect-ratio parent). It deliberately sets no `object-fit`, so an
188
+ `object-contain` you pass isn't fighting a built-in `object-cover`. It forfeits the
189
+ empty-state box — reach for it only when the parent guarantees the box.
190
+
191
+ ## The marker contract
192
+
193
+ The primitives are the sanctioned way to produce these attributes. The constants are
194
+ exported so a section's own browser-side code can query the DOM without hardcoding a
195
+ magic string.
196
+
197
+ | Constant | Attribute | On |
198
+ |---|---|---|
199
+ | `ATTR_SLOT_ID` | `data-slot-id` | the `Slot` wrapper / `Image`'s framed wrapper |
200
+ | `ATTR_SLOT_TEXT_LEAF` | `data-slot-text-leaf` | the text leaf inside a text slot |
201
+ | `ATTR_SLOT_ITEM` | `data-slot-item` | one item of a collection slot |
202
+ | `ATTR_SLOT_GROUP` | `data-slot-group` | the container wrapping a group's members |
203
+ | `ATTR_SECTION_INSTANCE_ID` | `data-section-instance-id` | the section root — **written by the platform**, not by a primitive |
204
+ | `ATTR_TR_ENHANCE` | `data-tr-enhance` | opt-in hook telling an enhancer to attach to this element |
205
+
206
+ Changing what an attribute means is a platform contract break, so these names are stable
207
+ across a major version of the kit.
208
+
209
+ ## Renderers stay pure
210
+
211
+ Renderers are pure functions of props: no fetches, no globals, no `useEffect` for data,
212
+ no `Date.now()` / `Math.random()`. They are rendered to static markup by the publisher
213
+ and re-mounted constantly by the editor. Interactivity that needs the browser attaches
214
+ through `data-tr-enhance`, not through render-time side effects.