@homepages/template-kit 0.4.0 → 0.5.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 (114) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +38 -27
  3. package/dist/base.css +6 -4
  4. package/dist/cli/dev/fill-state.js +5 -3
  5. package/dist/cli/link/overlay.js +1 -1
  6. package/dist/cli/new/scaffold/template/sections/hero/fixtures.ts +7 -7
  7. package/dist/contracts/slot-catalog.d.ts +2 -2
  8. package/dist/contracts/slot-catalog.js +3 -1
  9. package/dist/eslint/rules/no-raw-element.js +3 -2
  10. package/dist/fixtures/helpers.d.ts +23 -0
  11. package/dist/fixtures/helpers.js +50 -0
  12. package/dist/fixtures/sample-property.d.ts +7 -17
  13. package/dist/fixtures/sample-property.js +179 -57
  14. package/dist/fixtures/scenario.d.ts +20 -0
  15. package/dist/fixtures/scenarios/for-rent-condo.d.ts +5 -0
  16. package/dist/fixtures/scenarios/for-rent-condo.js +364 -0
  17. package/dist/fixtures/scenarios/index.d.ts +8 -0
  18. package/dist/fixtures/scenarios/index.js +13 -0
  19. package/dist/fixtures/scenarios/luxury-multi-unit.d.ts +5 -0
  20. package/dist/fixtures/scenarios/luxury-multi-unit.js +19 -0
  21. package/dist/fixtures/scenarios/sparse-single-family.d.ts +5 -0
  22. package/dist/fixtures/scenarios/sparse-single-family.js +233 -0
  23. package/dist/fixtures.d.ts +8 -2
  24. package/dist/fixtures.js +7 -2
  25. package/dist/index.d.ts +3 -2
  26. package/dist/index.js +2 -1
  27. package/dist/island-runtime.js +22 -7
  28. package/dist/islands/wrap.d.ts +1 -0
  29. package/dist/islands/wrap.js +1 -1
  30. package/dist/package.js +1 -1
  31. package/dist/primitives/Image.d.ts +1 -1
  32. package/dist/primitives/Video.d.ts +38 -0
  33. package/dist/primitives/Video.js +63 -0
  34. package/dist/primitives/index.d.ts +2 -1
  35. package/dist/rules/registry.js +3 -3
  36. package/dist/schema/index.d.ts +2 -2
  37. package/dist/schema/runtime-values.d.ts +20 -1
  38. package/dist/schema/section-props.d.ts +8 -1
  39. package/dist/schema/section-schema.d.ts +2 -2
  40. package/dist/schema/slot-types.d.ts +15 -1
  41. package/dist/schema/slot-types.js +34 -0
  42. package/dist/schema/synthesize-nullable.js +1 -0
  43. package/{docs → guide}/INDEX.md +7 -6
  44. package/{docs → guide}/check.md +2 -2
  45. package/{docs → guide}/islands.md +6 -1
  46. package/{docs → guide}/llms.txt +34 -14
  47. package/{docs → guide}/overview.md +4 -4
  48. package/{docs → guide}/pack.md +2 -2
  49. package/{docs → guide}/primitives.md +59 -11
  50. package/{docs → guide}/recipes/INDEX.md +1 -0
  51. package/{docs → guide}/recipes/collection-slot.md +10 -8
  52. package/guide/recipes/fixture-states.md +109 -0
  53. package/{docs → guide}/recipes/image-slot-crop.md +5 -3
  54. package/guide/recipes/pick-a-scenario.md +119 -0
  55. package/{docs → guide}/rules/INDEX.md +2 -2
  56. package/{docs → guide}/rules/fixtures-invalid.md +2 -2
  57. package/{docs → guide}/rules/no-raw-element.md +13 -8
  58. package/{docs → guide}/rules/render-invariant.md +4 -3
  59. package/{docs → guide}/schema-system.md +97 -16
  60. package/{docs → guide}/theme-and-css.md +3 -2
  61. package/guide/upgrading.md +61 -0
  62. package/{docs → guide}/vocabulary.md +3 -1
  63. package/package.json +9 -3
  64. package/dist/node_modules/@jridgewell/sourcemap-codec/dist/sourcemap-codec.js +0 -77
  65. package/dist/node_modules/magic-string/dist/magic-string.es.js +0 -939
  66. package/docs/recipes/fixture-states.md +0 -68
  67. /package/{docs → guide}/assets.md +0 -0
  68. /package/{docs → guide}/dev.md +0 -0
  69. /package/{docs → guide}/eslint.md +0 -0
  70. /package/{docs → guide}/new.md +0 -0
  71. /package/{docs → guide}/quickstart.md +0 -0
  72. /package/{docs → guide}/recipes/bind-property-fact.md +0 -0
  73. /package/{docs → guide}/recipes/fill-spec-decision.md +0 -0
  74. /package/{docs → guide}/recipes/interactive-island.md +0 -0
  75. /package/{docs → guide}/recipes/organize-section-folder.md +0 -0
  76. /package/{docs → guide}/recipes/prepare-submission.md +0 -0
  77. /package/{docs → guide}/recipes/second-template.md +0 -0
  78. /package/{docs → guide}/recipes/select-slot.md +0 -0
  79. /package/{docs → guide}/recipes/static-asset.md +0 -0
  80. /package/{docs → guide}/recipes/third-party-package.md +0 -0
  81. /package/{docs → guide}/rules/audit-severity.md +0 -0
  82. /package/{docs → guide}/rules/bundle-binary-asset.md +0 -0
  83. /package/{docs → guide}/rules/bundle-incomplete.md +0 -0
  84. /package/{docs → guide}/rules/css-reason.md +0 -0
  85. /package/{docs → guide}/rules/determinism-drift.md +0 -0
  86. /package/{docs → guide}/rules/image-bare-needs-reason.md +0 -0
  87. /package/{docs → guide}/rules/license-denied.md +0 -0
  88. /package/{docs → guide}/rules/lockfile-missing.md +0 -0
  89. /package/{docs → guide}/rules/lockfile-stale.md +0 -0
  90. /package/{docs → guide}/rules/manifest-invalid.md +0 -0
  91. /package/{docs → guide}/rules/missing-slot-marker.md +0 -0
  92. /package/{docs → guide}/rules/no-bare-css-import.md +0 -0
  93. /package/{docs → guide}/rules/no-client-directive-in-contract.md +0 -0
  94. /package/{docs → guide}/rules/no-client-runtime-in-server.md +0 -0
  95. /package/{docs → guide}/rules/no-css-import-from-render-path.md +0 -0
  96. /package/{docs → guide}/rules/no-hex.md +0 -0
  97. /package/{docs → guide}/rules/no-inline-style.md +0 -0
  98. /package/{docs → guide}/rules/no-nondeterminism.md +0 -0
  99. /package/{docs → guide}/rules/no-templates.md +0 -0
  100. /package/{docs → guide}/rules/parse-error.md +0 -0
  101. /package/{docs → guide}/rules/props-from-schema.md +0 -0
  102. /package/{docs → guide}/rules/schema-invalid.md +0 -0
  103. /package/{docs → guide}/rules/serializable-island-props.md +0 -0
  104. /package/{docs → guide}/rules/server-vs-client.md +0 -0
  105. /package/{docs → guide}/rules/sidebar-order.md +0 -0
  106. /package/{docs → guide}/rules/single-react.md +0 -0
  107. /package/{docs → guide}/rules/size-assets.md +0 -0
  108. /package/{docs → guide}/rules/size-island-bundle.md +0 -0
  109. /package/{docs → guide}/rules/size-renderer-bundle.md +0 -0
  110. /package/{docs → guide}/rules/size-section-css.md +0 -0
  111. /package/{docs → guide}/rules/slot-group-marker.md +0 -0
  112. /package/{docs → guide}/rules/slot-marker-literal.md +0 -0
  113. /package/{docs → guide}/rules/typecheck.md +0 -0
  114. /package/{docs → guide}/rules/unlayered-fence.md +0 -0
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  purpose: The mental model in one page — workspace, templates, sections, and the server-vs-client mapping.
3
3
  status: living
4
- related: [schema-system.md, islands.md, rules/server-vs-client.md]
5
- updated: 2026-07-15
4
+ related: [schema-system.md, islands.md, rules/server-vs-client.md, upgrading.md]
5
+ updated: 2026-07-16
6
6
  ---
7
7
  # Overview
8
8
 
@@ -40,8 +40,8 @@ A **slot** is editable content; an **option** is a behavior switch — see
40
40
  the distinction and [the closed vocabulary](vocabulary.md) for the types.
41
41
  **Fill** is how AI assigns slot values via `fill-spec.ts`'s decisions, covered
42
42
  in [schema-system.md](schema-system.md#fill-spects--how-ai-fills-the-slots).
43
- **Fixtures** are the sample content a section previews against, built on the
44
- kit's golden property dataset — see
43
+ **Fixtures** are the sample content a section previews against, sourced from the
44
+ kit's golden scenarios by the fill-by-source rule — see
45
45
  [schema-system.md](schema-system.md#fixturests--content-for-local-preview).
46
46
 
47
47
  ## Server vs client — your Next.js instinct, mapped
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  purpose: The `template-kit pack` command — what it produces, what it refuses, and why.
3
3
  status: living
4
- related: [INDEX.md, check.md]
5
- updated: 2026-07-15
4
+ related: [INDEX.md, check.md, upgrading.md]
5
+ updated: 2026-07-16
6
6
  ---
7
7
  # template-kit pack
8
8
 
@@ -1,20 +1,21 @@
1
1
  ---
2
- purpose: The five contract primitives a section renderer must use, and the marker attributes they emit.
2
+ purpose: The six contract primitives a section renderer must use, and the marker attributes they emit.
3
3
  status: living
4
- related: [schema-system.md, theme-and-css.md, eslint.md]
5
- updated: 2026-07-15
4
+ related: [schema-system.md, theme-and-css.md, eslint.md, upgrading.md]
5
+ updated: 2026-07-16
6
6
  ---
7
7
  # Contract primitives
8
8
 
9
9
  Everything here is imported from the package root:
10
10
 
11
11
  ```tsx
12
- import { Image, Section, Slot, SlotGroup, SlotItem } from "@homepages/template-kit";
12
+ import { Image, Section, Slot, SlotGroup, SlotItem, Video } from "@homepages/template-kit";
13
13
  ```
14
14
 
15
- The kit ships **five components, and nothing else**. That is deliberate: a primitive
15
+ The kit ships **six components, and nothing else**. That is deliberate: a primitive
16
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.
17
+ box model, the editor's marker attributes, the responsive-image machinery, the
18
+ poster/playback contract for hosted video.
18
19
  Presentation is yours. Headings, links, layout wells, icons, lists, cards: write them
19
20
  as JSX in your section, or reach for a package.
20
21
 
@@ -22,9 +23,10 @@ as JSX in your section, or reach for a package.
22
23
  |---|---|---|
23
24
  | `Section` | `class="tr-section"` on the section root | always — every renderer roots at exactly one |
24
25
  | `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
+ | `SlotItem` | `data-slot-item` | around each item of an `object_list` / `image_collection` / `video_collection` slot |
26
27
  | `SlotGroup` | `data-slot-group` | around the members of a group declared in `meta.groups` |
27
28
  | `Image` | `<picture>` / `srcset` / the empty-state box | every image — sections never write a raw `<img>` |
29
+ | `Video` | the poster frame / the playback config / the empty-state box | every video — sections never write a raw `<video>` |
28
30
 
29
31
  A slot with no marker in the DOM **cannot be selected or edited**. The markers are the
30
32
  only thing connecting the pixels a user clicks to the schema key behind them.
@@ -86,8 +88,8 @@ needs no wrapper. `<Slot>` is for everything else.
86
88
 
87
89
  ## `SlotItem` — the per-item handle in a collection
88
90
 
89
- A collection slot (`object_list`, `image_collection`) needs **both** markers: `<Slot>`
90
- marks the whole array, `<SlotItem>` marks one row of it.
91
+ A collection slot (`object_list`, `image_collection`, `video_collection`) needs **both**
92
+ markers: `<Slot>` marks the whole array, `<SlotItem>` marks one row of it.
91
93
 
92
94
  ```tsx
93
95
  <Slot id="amenities">
@@ -193,6 +195,51 @@ inside its own aspect-ratio parent). It deliberately sets no `object-fit`, so an
193
195
  `object-contain` you pass isn't fighting a built-in `object-cover`. It forfeits the
194
196
  empty-state box — reach for it only when the parent guarantees the box.
195
197
 
198
+ ## `Video` — the hosted-video primitive
199
+
200
+ Sections never write a raw `<video>`. `Video` owns the poster frame, the author-declared
201
+ playback config, and the same layout box that survives a missing source.
202
+
203
+ ```tsx
204
+ <Video
205
+ slotId="tour_video"
206
+ src={slots.tour_video.url}
207
+ poster={slots.tour_video.poster}
208
+ alt={slots.tour_video.alt}
209
+ playback={schema.slots.tour_video.playback}
210
+ aspectRatio="16 / 9"
211
+ />
212
+ ```
213
+
214
+ | Prop | Meaning |
215
+ |---|---|
216
+ | `src` | The rendition URL. Required prop; `null`/empty is a supported state, not a bug. |
217
+ | `poster` | Poster-frame URL. The first paint, and the entire render while `src` is empty. |
218
+ | `alt` | Required. Empty string for a purely decorative loop. |
219
+ | `slotId` | Emits `data-slot-id` on the wrapper — a video slot needs no `<Slot>` around it. |
220
+ | `aspectRatio` | CSS `aspect-ratio` reserved on the wrapper. Survives a missing `src`. |
221
+ | `playback` | The slot's [`playback` config](schema-system.md#video-slots). Absent ⇒ a plain controls player. |
222
+ | `width` / `height` | Intrinsic rendition dimensions (`VideoValue.width`/`height`) — reserve the box before metadata loads. |
223
+
224
+ **Framed, always.** There is no `bare` escape hatch: a wrapper `div` carries the slot
225
+ marker and the aspect-ratio box, and absolutely-positions the inner `<video>` with
226
+ `object-cover`. When `src` is empty the wrapper renders the poster alone — or a neutral
227
+ `role="img"` rect if there is no poster — keeping the layout box and the slot selectable.
228
+ Both empty states are routine: an unfilled video slot is a thin listing, and a poster with
229
+ no rendition is what a video still transcoding looks like.
230
+
231
+ The placeholder fill rides on the same `.tr-image-frame` class `<Image>` uses (from
232
+ [`base.css`](theme-and-css.md#basecss-requires-a-tailwind-entry)) — one neutral rect, one
233
+ `--tr-image-frame-bg` token, so a template retints both frames once.
234
+
235
+ **Playback is passed through verbatim** — the primitive infers nothing from the asset.
236
+ The config is authored on the slot, not chosen here; see
237
+ [Video slots](schema-system.md#video-slots) for the fields and the `autoplay`-requires-`muted`
238
+ rule.
239
+
240
+ There is no cropping and no responsive ladder: one progressive MP4 is served per video,
241
+ and the poster is derived upstream.
242
+
196
243
  ## The marker contract
197
244
 
198
245
  The primitives are the sanctioned way to produce these attributes. The constants are
@@ -201,14 +248,15 @@ magic string.
201
248
 
202
249
  | Constant | Attribute | On |
203
250
  |---|---|---|
204
- | `ATTR_SLOT_ID` | `data-slot-id` | the `Slot` wrapper / `Image`'s framed wrapper |
251
+ | `ATTR_SLOT_ID` | `data-slot-id` | the `Slot` wrapper / `Image`'s and `Video`'s framed wrapper |
205
252
  | `ATTR_SLOT_TEXT_LEAF` | `data-slot-text-leaf` | the text leaf inside a text slot |
206
253
  | `ATTR_SLOT_ITEM` | `data-slot-item` | one item of a collection slot |
207
254
  | `ATTR_SLOT_GROUP` | `data-slot-group` | the container wrapping a group's members |
208
255
  | `ATTR_SECTION_INSTANCE_ID` | `data-section-instance-id` | the section root — **written by the platform**, not by a primitive |
209
256
 
210
257
  Changing what an attribute means is a platform contract break, so these names are stable
211
- across a major version of the kit.
258
+ across patch releases of the kit; a rename is exactly the kind of breaking change that
259
+ ships as a minor bump (see [Upgrading the kit](upgrading.md)).
212
260
 
213
261
  ## Renderers stay pure
214
262
 
@@ -18,6 +18,7 @@ copy the shapes from its recipe, not from memory.
18
18
  | give the user a closed set of choices | [`select-slot.md`](select-slot.md) |
19
19
  | add a repeating list the user edits per item (unit table, cards, gallery) | [`collection-slot.md`](collection-slot.md) |
20
20
  | have AI write a slot's content | [`fill-spec-decision.md`](fill-spec-decision.md) |
21
+ | source a fixture from a real property, not invented values | [`pick-a-scenario.md`](pick-a-scenario.md) |
21
22
  | preview a section's structural edge cases / states | [`fixture-states.md`](fixture-states.md) |
22
23
  | make part of a section interactive (an island) | [`interactive-island.md`](interactive-island.md) |
23
24
  | organize a section's files beyond the four reserved | [`organize-section-folder.md`](organize-section-folder.md) |
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  purpose: Add a repeating list whose items the editor selects and edits one at a time.
3
3
  status: living
4
- related: [../primitives.md, ../vocabulary.md, ../schema-system.md, bind-property-fact.md]
4
+ related: [../primitives.md, ../vocabulary.md, ../schema-system.md, bind-property-fact.md, pick-a-scenario.md]
5
5
  updated: 2026-07-16
6
6
  ---
7
7
  # A collection slot — a repeating, per-item list
@@ -58,10 +58,10 @@ type UnitRow = { unit_label: string; beds: number; baths: number; sqft: number;
58
58
  ```
59
59
 
60
60
  ```ts
61
- // fixtures.ts — supply the array from the golden property
62
- import { allPhotos, mkResponsive, sampleProperty } from "@homepages/template-kit/fixtures";
61
+ // fixtures.ts — supply the array from a scenario's facts
62
+ import { scenarios } from "@homepages/template-kit/fixtures";
63
63
  // ...in base.slots:
64
- units: sampleProperty.units,
64
+ units: scenarios.luxuryMultiUnit.facts.units,
65
65
  ```
66
66
 
67
67
  Two contracts to know before you widen this:
@@ -73,9 +73,10 @@ Two contracts to know before you widen this:
73
73
  - **An `item` needs more than one field, or a non-scalar one** — a lone scalar
74
74
  field is rejected; use a `list` slot for a flat list of scalars.
75
75
 
76
- `object_list` is one of four collection types. A gallery of framed photos is an
77
- `image_collection`, a flat scalar list is a `list`, neighborhood points are a
78
- `poi_list` — same `<SlotItem>` render pattern, different value shape. See
76
+ `object_list` is one of five collection types. A gallery of framed photos is an
77
+ `image_collection`, several hosted videos are a `video_collection`, a flat scalar list is
78
+ a `list`, neighborhood points are a `poi_list` — same `<SlotItem>` render pattern,
79
+ different value shape. See
79
80
  [the slot types](../vocabulary.md#slot-types) for which to pick.
80
81
 
81
82
  ## Commands
@@ -92,7 +93,8 @@ Verify with [the author loop](../quickstart.md#the-author-loop).
92
93
  the `SlotItem` / `SlotGroup` contract: both markers a collection needs, and why
93
94
  `index` must be the row's array position.
94
95
  - [The closed vocabulary](../vocabulary.md#slot-types) — every collection slot type
95
- (`object_list` / `image_collection` / `poi_list` / `list`) and the `direct()` List sources.
96
+ (`object_list` / `image_collection` / `video_collection` / `poi_list` / `list`) and the
97
+ `direct()` List sources.
96
98
  - [The schema system](../schema-system.md#fill-spects--how-ai-fills-the-slots) — the
97
99
  `produced_by` decision types (`structured-list`, `list-extract`, `image-collection`)
98
100
  for a collection AI fills instead of binding to a fact.
@@ -0,0 +1,109 @@
1
+ ---
2
+ purpose: Preview a section's structural edge cases.
3
+ status: living
4
+ related: [../schema-system.md, ../dev.md, pick-a-scenario.md]
5
+ updated: 2026-07-16
6
+ ---
7
+ # Preview a section's structural edge cases
8
+
9
+ ## Goal
10
+
11
+ Preview a section's structural edge cases.
12
+
13
+ ## The delta
14
+
15
+ Add entries under `states` in `fixtures.ts` — each one overrides part of
16
+ `base`, the same pattern the starter's `left` and `long-blurb` states use.
17
+ A `states` entry is for a DESIGNED case, not raw coverage: nullability
18
+ and empty-slot coverage (every optional slot rendered empty) is derived
19
+ automatically from `schema.ts`, so a fixture state is for a case the
20
+ section renders *differently* — a content-length branch, an option branch,
21
+ or several overrides combined.
22
+
23
+ ```ts
24
+ // fixtures.ts — a content-length case and an option-branch case
25
+ const fixtures: FixtureModule = {
26
+ base: {
27
+ slots: {
28
+ headline: "101 Queensway, Jamaica Plain, MA 02130",
29
+ summary: "A 12-unit mansard Victorian moments from the Emerald Necklace.",
30
+ },
31
+ options: { align: "center" },
32
+ },
33
+ states: {
34
+ // Content-length branch: exercises a long value's wrap/expand behavior.
35
+ "long-summary": {
36
+ slots: {
37
+ summary:
38
+ "A 12-unit mansard Victorian moments from the Emerald Necklace, with a " +
39
+ "terrazzo lobby, an on-site gym, quartz kitchens throughout, and a " +
40
+ "top-floor penthouse with a private rooftop terrace and skyline views.",
41
+ },
42
+ },
43
+ // Option branch: exercises the layout under the other enum value.
44
+ left: { options: { align: "left" } },
45
+ },
46
+ };
47
+ ```
48
+
49
+ Each `states` key names a state; its value overrides `slots` and/or
50
+ `options` on top of `base` — omit either key to inherit `base`'s value
51
+ unchanged. Each state is a selectable fixture in the dev preview, so every one
52
+ you add is there to render and review alongside `base`.
53
+
54
+ ### Swap the whole scenario
55
+
56
+ A state can also be designed to look different against a *sparser property* — fewer
57
+ units, no logo, no video — rather than a content-length or option branch. Source that
58
+ kind of state from a different scenario, not from hand-picked overrides:
59
+
60
+ ```ts
61
+ // fixtures.ts — a `sparse` state sourced from a different scenario
62
+ import { scenarios } from "@homepages/template-kit/fixtures";
63
+
64
+ const luxury = scenarios.luxuryMultiUnit;
65
+ const sparse = scenarios.sparseSingleFamily;
66
+
67
+ const fixtures: FixtureModule = {
68
+ base: {
69
+ slots: {
70
+ headline: luxury.facts.address,
71
+ summary: "A 12-unit mansard Victorian moments from the Emerald Necklace.",
72
+ },
73
+ options: { align: "center" },
74
+ },
75
+ states: {
76
+ sparse: {
77
+ slots: {
78
+ headline: sparse.facts.address,
79
+ summary:
80
+ "A classic center-hall colonial with an updated kitchen and original " +
81
+ "hardwood floors throughout.",
82
+ },
83
+ },
84
+ },
85
+ };
86
+ ```
87
+
88
+ Fill every slot in a scenario-swapped state the same way you filled `base` — by the
89
+ slot's fill-spec source, never with a placeholder. See
90
+ [the fill-by-source rule](../schema-system.md#fill-every-slot-from-the-scenario-by-its-fill-spec-source)
91
+ and [Pick a scenario](pick-a-scenario.md) for the picker and helper cheat-sheet.
92
+
93
+ ## Commands
94
+
95
+ None — this is a pure content edit. No CLI command beyond the author loop below.
96
+
97
+ ## Verify
98
+
99
+ Verify with [the author loop](../quickstart.md#the-author-loop).
100
+
101
+ ## See also
102
+
103
+ - [The schema system](../schema-system.md#fixturests--content-for-local-preview) —
104
+ the `fixtures.ts` contract: `base`, `states`, the scenario breadth table, and the
105
+ fill-by-source rule.
106
+ - [Pick a scenario](pick-a-scenario.md) — the scenario picker and helper cheat-sheet a
107
+ scenario-swapped state builds on.
108
+ - [`template-kit dev`](../dev.md) — the preview server that renders each
109
+ fixture state across the breakpoint ladder.
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  purpose: Add an image slot the editor crops to a fixed ratio.
3
3
  status: living
4
- related: [../schema-system.md, ../primitives.md, ../rules/image-bare-needs-reason.md]
5
- updated: 2026-07-15
4
+ related: [../schema-system.md, ../primitives.md, ../rules/image-bare-needs-reason.md, pick-a-scenario.md]
5
+ updated: 2026-07-16
6
6
  ---
7
7
  # Image slot with a locked crop
8
8
 
@@ -52,7 +52,9 @@ interior_image: {
52
52
 
53
53
  ```ts
54
54
  // fixtures.ts — a photo + a matching mkResponsive() height
55
- const interiorImage = allPhotos[3]!;
55
+ import { mkResponsive, scenarios } from "@homepages/template-kit/fixtures";
56
+
57
+ const interiorImage = scenarios.luxuryMultiUnit.photos[3]!;
56
58
  // ...
57
59
  interior_image: {
58
60
  photo_id: interiorImage.id,
@@ -0,0 +1,119 @@
1
+ ---
2
+ purpose: Source a fixture's slots from real property data, by the fill-by-source rule.
3
+ status: living
4
+ related: [../schema-system.md, fixture-states.md, ../vocabulary.md]
5
+ updated: 2026-07-16
6
+ ---
7
+ # Pick a scenario for a fixture
8
+
9
+ ## Goal
10
+
11
+ Fill a section's `fixtures.ts` from the kit's golden scenarios instead of inventing
12
+ values, so preview stays honest and every fixture is grounded in a real,
13
+ self-consistent property.
14
+
15
+ ## The delta
16
+
17
+ ### Pick a scenario
18
+
19
+ Import `scenarios` from the fixtures entry — never the root barrel; dev-only sample
20
+ data must never reach a published renderer bundle's import graph — and pick the one
21
+ whose breadth matches the state you're building:
22
+
23
+ ```ts
24
+ import { scenarios } from "@homepages/template-kit/fixtures";
25
+
26
+ const luxury = scenarios.luxuryMultiUnit; // 12 units, 3 contacts, both logos, video + 3D tour
27
+ const sparse = scenarios.sparseSingleFamily; // 1 unit, 1 contact, no logos, no media
28
+ const rent = scenarios.forRentCondo; // 1 unit, 2 contacts, both logos, video only
29
+ ```
30
+
31
+ `base` almost always builds on `luxuryMultiUnit` — it's the breadth ceiling, so a
32
+ layout that survives it survives everything smaller. Reach for `sparseSingleFamily`
33
+ or `forRentCondo` for a [`states`](fixture-states.md) entry designed to look
34
+ different against a thinner property.
35
+
36
+ | Scenario | units | listing_intent | property_type | contacts | pois | photos | contact logos | media |
37
+ |---|---|---|---|---|---|---|---|---|
38
+ | `luxuryMultiUnit` | 12 | for_sale | multi_family | 3 | 24 | 20 | both logos | video + 3D tour |
39
+ | `sparseSingleFamily` | 1 | for_sale | single_family | 1 | 4 | 5 | none (null) | none (null) |
40
+ | `forRentCondo` | 1 | for_rent | condo_townhome | 2 | 8 | 10 | both logos | video only |
41
+
42
+ ### Fill every slot by its fill-spec source
43
+
44
+ Don't invent a value. Every slot in `schema.ts` already declares how it's filled in
45
+ production — `fixtures.ts` mirrors that:
46
+
47
+ | Slot's fill-spec source | How the fixture fills it |
48
+ |---|---|
49
+ | direct (a fact) | read it off the scenario — `s.facts.address`, `primaryContact(s).fields.phone` |
50
+ | derived | a helper — `propertyMetrics(s)`, `poisByCategory(s)`, `contactCards(s)` |
51
+ | ai | a short sample fill **grounded in the scenario's facts** — a headline about *this* property, never lorem ipsum or invented facts |
52
+
53
+ A fixture that gets this wrong is worse than a missing one: a slot bound
54
+ `direct("beds")` whose fixture value doesn't match `s.facts.units[0].beds` teaches
55
+ nothing about the layout it's supposed to preview.
56
+
57
+ ### The helper cheat-sheet
58
+
59
+ ```ts
60
+ import {
61
+ contactCards,
62
+ mkResponsive,
63
+ poisByCategory,
64
+ primaryContact,
65
+ propertyMetrics,
66
+ scenarios,
67
+ } from "@homepages/template-kit/fixtures";
68
+
69
+ const s = scenarios.luxuryMultiUnit;
70
+ ```
71
+
72
+ | Helper | Returns |
73
+ |---|---|
74
+ | `primaryContact(s)` | The scenario's primary contact (`ContactFixture`, position 0 by construction) — throws if the scenario has none |
75
+ | `contactCards(s)` | Every attached contact, in order (`ContactFixture[]`) |
76
+ | `poisByCategory(s)` | The scenario's POIs bucketed by category (`Record<PoiFixture["category"], PoiFixture[]>`) |
77
+ | `propertyMetrics(s)` | `beds`/`baths`/`sqft`/`price` off the lowest-position unit, `bedsMin`/`bedsMax`/`bathsMin`/`bathsMax`/`sqftMax`/`priceStart` across all units, and `addressLine1`/`addressLine2` |
78
+ | `mkResponsive(url, width, height)` | A deterministic `ResponsiveImage` descriptor so `<Image responsive>` can be previewed and snapshotted |
79
+
80
+ `propertyMetrics` returns kit fact vocabulary — `addressLine1`/`addressLine2`, not a
81
+ template's own slot names, so the kit never learns what a section calls its slots. Map
82
+ it to your slots at the call site:
83
+
84
+ ```ts
85
+ const m = propertyMetrics(s);
86
+ // ...in base.slots:
87
+ address_street: m.addressLine1,
88
+ address_locality: m.addressLine2,
89
+ ```
90
+
91
+ Fields the helpers don't cover are readable straight off the scenario: `s.photos`,
92
+ `s.pois`, `s.amenities`, `s.contacts`, `s.media`, and `s.facts` (the raw
93
+ `PropertyFacts`). A contact's org name and logos (`logo_light`/`logo_dark`) live on
94
+ `ContactFixture["fields"]`, not on a sibling "brand" object — read them off
95
+ `primaryContact(s).fields` or `s.contacts`.
96
+
97
+ ### Add a scenario-driven state
98
+
99
+ A `states` entry designed to look different against a sparser property sources from a
100
+ different scenario, not from overridden literals — see
101
+ [Preview a section's structural edge cases](fixture-states.md#swap-the-whole-scenario)
102
+ for the full pattern.
103
+
104
+ ## Commands
105
+
106
+ None — this is a pure content edit. No CLI command beyond the author loop below.
107
+
108
+ ## Verify
109
+
110
+ Verify with [the author loop](../quickstart.md#the-author-loop).
111
+
112
+ ## See also
113
+
114
+ - [The schema system](../schema-system.md#fixturests--content-for-local-preview) —
115
+ the `fixtures.ts` contract, the scenario breadth table, and the fill-by-source rule.
116
+ - [Preview a section's structural edge cases](fixture-states.md) — `states`, including
117
+ a scenario-swapped state.
118
+ - [The closed vocabulary](../vocabulary.md#direct-sources) — the `direct()` fields a
119
+ scenario's `facts` supplies.
@@ -7,7 +7,7 @@ updated: 2026-07-15
7
7
  # Rules
8
8
 
9
9
  Every authoring rule the kit enforces is printed under one namespace, `template-kit/<id>`,
10
- and resolves to exactly one page here: `docs/rules/<id>.md`.
10
+ and resolves to exactly one page here: `guide/rules/<id>.md`.
11
11
 
12
12
  **One namespace, two venues.** Some rules are properties of **one file** (a `Date.now()`
13
13
  in a Renderer) and are enforced by the ESLint preset. Others are properties of the
@@ -28,7 +28,7 @@ and it is explained there rather than on each of them.
28
28
  | [`no-client-directive-in-contract`](no-client-directive-in-contract.md) | eslint | No "use client" in a section's four contract files. |
29
29
  | [`props-from-schema`](props-from-schema.md) | eslint | A Renderer's Props type must be derived from schema.ts, not hand-written. |
30
30
  | [`no-inline-style`](no-inline-style.md) | eslint | No inline style= in server-rendered JSX. Islands are exempt. |
31
- | [`no-raw-element`](no-raw-element.md) | eslint | No raw `<section>` or `<img>` — use `<Section>` and `<Image>`. |
31
+ | [`no-raw-element`](no-raw-element.md) | eslint | No raw `<section>`, `<img>` or `<video>` — use `<Section>`, `<Image>` and `<Video>`. |
32
32
  | [`image-bare-needs-reason`](image-bare-needs-reason.md) | eslint | `<Image bare>` needs a `// bare:` comment saying why it skips the frame. |
33
33
  | [`slot-marker-literal`](slot-marker-literal.md) | eslint | A slot-marker key must be a string literal, not an expression. |
34
34
  | [`no-hex`](no-hex.md) | eslint | No hard-coded colours — the one legal colour function is a `color-mix()` over theme-derived arguments. Islands are NOT exempt. |
@@ -2,7 +2,7 @@
2
2
  purpose: The template-kit/fixtures-invalid authoring rule — what it enforces, why, and how to fix it.
3
3
  status: living
4
4
  related: [INDEX.md, ../schema-system.md, schema-invalid.md]
5
- updated: 2026-07-14
5
+ updated: 2026-07-16
6
6
  ---
7
7
  # template-kit/fixtures-invalid
8
8
 
@@ -21,7 +21,7 @@ The default export of `fixtures.ts` must satisfy the fixture-module shape:
21
21
  variants: { /* optional */ },
22
22
  },
23
23
  option_matrix: [ /* optional: { label?, options } entries */ ],
24
- states: { /* optional: named scenario → { label?, slots?, options?, anchors?, variants? } */ },
24
+ states: { /* optional: named state → { label?, slots?, options?, anchors?, variants? } */ },
25
25
  }
26
26
  ```
27
27
 
@@ -6,28 +6,29 @@ updated: 2026-07-14
6
6
  ---
7
7
  # template-kit/no-raw-element
8
8
 
9
- > **Exactly two** raw elements are banned: `<section>` and `<img>`. Every other tag is
10
- > yours.
9
+ > **Exactly three** raw elements are banned: `<section>`, `<img>` and `<video>`. Every
10
+ > other tag is yours.
11
11
 
12
12
  ## Rule
13
13
 
14
- Two tags, and no others:
14
+ Three tags, and no others:
15
15
 
16
16
  | Raw tag | Use instead |
17
17
  |---|---|
18
18
  | `<section>` | `<Section>` |
19
19
  | `<img>` | `<Image>` |
20
+ | `<video>` | `<Video>` |
20
21
 
21
22
  That is the entire rule. **`<a>`, `<h1>`–`<h6>`, `<div>`, `<ul>`, `<button>`, `<picture>`,
22
23
  `<figure>` — every other element is yours to write.** Presentation is the template's own
23
24
  JSX; the kit is not trying to own your markup. If you have concluded "the kit bans raw
24
25
  HTML", that is exactly backwards.
25
26
 
26
- There is no island exemption: the two tags are banned in a `"use client"` file too.
27
+ There is no island exemption: the three tags are banned in a `"use client"` file too.
27
28
 
28
29
  ## Reason
29
30
 
30
- Each of the two banned tags has a **contract-bearing primitive that must emit it**, and
31
+ Each of the three banned tags has a **contract-bearing primitive that must emit it**, and
31
32
  writing the raw tag skips the contract.
32
33
 
33
34
  - **`<Section>`** emits `<section class="tr-section">`, which is full-bleed by contract:
@@ -40,9 +41,13 @@ writing the raw tag skips the contract.
40
41
  or a broken-image icon on a real customer's page — and a missing image is a **supported
41
42
  state**, not a bug: it happens whenever a listing is thin. `<Image slotId>` also emits
42
43
  the slot marker itself, so the image stays selectable in the editor.
44
+ - **`<Video>`** owns the poster frame, the author-declared playback config, and the same
45
+ fallback rect — a video slot may be empty (a thin listing) or still transcoding, and
46
+ neither may collapse the layout. A raw `<video>` also skips the slot marker, so the
47
+ video stops being selectable in the editor.
43
48
 
44
- The two tags are banned because they are the two the platform has to be able to trust.
45
- Everything else carries no contract, so nothing is gained by policing it.
49
+ The three tags are banned because they are the three the platform has to be able to
50
+ trust. Everything else carries no contract, so nothing is gained by policing it.
46
51
 
47
52
  ## Fix
48
53
 
@@ -95,6 +100,6 @@ The `<h2>` stays exactly as it was. `Image` takes `src`/`alt` directly — a `nu
95
100
 
96
101
  ## See also
97
102
 
98
- - [Contract primitives](../primitives.md) — all five primitives and the markers they emit.
103
+ - [Contract primitives](../primitives.md) — every primitive and the markers they emit.
99
104
  - [`image-bare-needs-reason`](image-bare-needs-reason.md) — the escape hatch that drops
100
105
  `<Image>`'s frame, and what it costs.
@@ -2,7 +2,7 @@
2
2
  purpose: The template-kit/render-invariant authoring rule — what it enforces, why, and how to fix it.
3
3
  status: living
4
4
  related: [INDEX.md, ../primitives.md, ../schema-system.md]
5
- updated: 2026-07-14
5
+ updated: 2026-07-16
6
6
  ---
7
7
  # template-kit/render-invariant
8
8
 
@@ -51,7 +51,8 @@ routine, not exceptional** — and your markup has to say what happens then.
51
51
  Worth being precise, because the obvious guesses are wrong. Rendering a nullish slot as a
52
52
  **child** is safe: React renders `null` and `undefined` as nothing, so `<p>{slots.year_built}</p>`
53
53
  on a thin listing is an empty `<p>`, not the text `undefined`. And a list slot is **never
54
- `null`** — `list`, `object_list`, `poi_list` and `image_collection` always arrive as an array
54
+ `null`** — `list`, `object_list`, `poi_list`, `image_collection` and `video_collection`
55
+ always arrive as an array
55
56
  (empty when absent), so `.map` cannot throw and `?? []` buys you nothing.
56
57
 
57
58
  The defects come from three narrower places:
@@ -107,7 +108,7 @@ export function Renderer({ slots }: Props) {
107
108
  }
108
109
  ```
109
110
 
110
- On the golden property this renders perfectly. On a listing with no year, an unpriced unit,
111
+ On the richest golden scenario this renders perfectly. On a listing with no year, an unpriced unit,
111
112
  and no floor plan it ships `built null`, `2 Bed · undefined`, `NaN sq ft heated`, and
112
113
  `<img src="">`.
113
114