@homepages/template-kit 0.2.0 → 0.4.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 (165) hide show
  1. package/CHANGELOG.md +222 -0
  2. package/README.md +96 -17
  3. package/dist/asset-modules.d.ts +81 -0
  4. package/dist/base.css +9 -0
  5. package/dist/cli/check/config.js +41 -0
  6. package/dist/cli/check/css.js +131 -0
  7. package/dist/cli/check/diagnostics.js +32 -0
  8. package/dist/cli/check/index.js +87 -0
  9. package/dist/cli/check/loader.js +146 -0
  10. package/dist/cli/check/paths.js +15 -0
  11. package/dist/cli/check/relativize.js +18 -0
  12. package/dist/cli/check/render-invariants.js +24 -0
  13. package/dist/cli/check/resolve-tool.js +38 -0
  14. package/dist/cli/check/section-assets.js +58 -0
  15. package/dist/cli/check/stages/deps.js +337 -0
  16. package/dist/cli/check/stages/lint.js +46 -0
  17. package/dist/cli/check/stages/render.js +101 -0
  18. package/dist/cli/check/stages/size.js +274 -0
  19. package/dist/cli/check/stages/tree.js +206 -0
  20. package/dist/cli/check/stages/typecheck.js +46 -0
  21. package/dist/cli/check/stages/validate/catalog-exhaustiveness.js +18 -0
  22. package/dist/cli/check/stages/validate/facts.js +18 -0
  23. package/dist/cli/check/stages/validate/formatter-contract.js +14 -0
  24. package/dist/cli/check/stages/validate/group-contract.js +21 -0
  25. package/dist/cli/check/stages/validate/index.js +66 -0
  26. package/dist/cli/check/stages/validate/list-scalar-contract.js +22 -0
  27. package/dist/cli/check/stages/validate/nav-contract.js +93 -0
  28. package/dist/cli/check/stages/validate/nested-slot-contract.js +12 -0
  29. package/dist/cli/check/stages/validate/object-list-contract.js +31 -0
  30. package/dist/cli/check/stages/validate/orchestrator.js +241 -0
  31. package/dist/cli/check/stages/validate/produced-by-contract.js +18 -0
  32. package/dist/cli/check/stages/validate/row-contract.js +31 -0
  33. package/dist/cli/check/stages/validate/select-contract.js +13 -0
  34. package/dist/cli/check/stages/validate/source-contract.js +21 -0
  35. package/dist/cli/check/stages/validate/variant-contract.js +15 -0
  36. package/dist/cli/check/workspace.js +86 -0
  37. package/dist/cli/dev/build-css.js +91 -0
  38. package/dist/cli/dev/catalog.js +35 -0
  39. package/dist/cli/dev/compose-template.js +38 -0
  40. package/dist/cli/dev/content-override.js +58 -0
  41. package/dist/cli/dev/discover.js +35 -0
  42. package/dist/cli/dev/fill-state.js +75 -0
  43. package/dist/cli/dev/index.js +32 -0
  44. package/dist/cli/dev/inspect.js +38 -0
  45. package/dist/cli/dev/island-bootstrap.js +38 -0
  46. package/dist/cli/dev/island-map.js +35 -0
  47. package/dist/cli/dev/island-transform.js +31 -0
  48. package/dist/cli/dev/manifest-instances.js +31 -0
  49. package/dist/cli/dev/render-section.js +24 -0
  50. package/dist/cli/dev/screenshot-target.js +24 -0
  51. package/dist/cli/dev/section-page.js +54 -0
  52. package/dist/cli/dev/server.js +480 -0
  53. package/dist/cli/dev/slot-schema.js +12 -0
  54. package/dist/cli/dev/structure-summary.js +118 -0
  55. package/dist/cli/dev/tailwind.js +32 -0
  56. package/dist/cli/dev/vite-server.js +33 -0
  57. package/dist/cli/dev/workspace.js +63 -0
  58. package/dist/cli/link/index.js +74 -0
  59. package/dist/cli/link/overlay.js +59 -0
  60. package/dist/cli/link/watch.js +25 -0
  61. package/dist/cli/new/emit.js +48 -0
  62. package/dist/cli/new/index.js +69 -0
  63. package/dist/cli/new/scaffold/section/_Renderer.tsx +20 -0
  64. package/dist/cli/new/scaffold/section/_fill-spec.ts +18 -0
  65. package/dist/cli/new/scaffold/section/_fixtures.ts +13 -0
  66. package/dist/cli/new/scaffold/section/_schema.ts +24 -0
  67. package/dist/cli/new/scaffold/template/_manifest.json +9 -0
  68. package/dist/cli/new/scaffold/template/sections/hero/ExpandableText.tsx +45 -0
  69. package/dist/cli/new/scaffold/template/sections/hero/Renderer.tsx +40 -0
  70. package/dist/cli/new/scaffold/template/sections/hero/components/Feature.tsx +14 -0
  71. package/dist/cli/new/scaffold/template/sections/hero/fill-spec.ts +29 -0
  72. package/dist/cli/new/scaffold/template/sections/hero/fixtures.ts +41 -0
  73. package/dist/cli/new/scaffold/template/sections/hero/schema.ts +60 -0
  74. package/dist/cli/new/scaffold/template/theme.css +24 -0
  75. package/dist/cli/new/scaffold/template/theme.ts +27 -0
  76. package/dist/cli/new/scaffold-assets.js +20 -0
  77. package/dist/cli/pack/collect.js +44 -0
  78. package/dist/cli/pack/guards.js +57 -0
  79. package/dist/cli/pack/index.js +66 -0
  80. package/dist/cli/pack/manifest.js +15 -0
  81. package/dist/cli/pack/zip.js +86 -0
  82. package/dist/cli/theme/generate.js +43 -0
  83. package/dist/cli/theme/index.js +33 -0
  84. package/dist/cli/theme/load-theme.js +69 -0
  85. package/dist/cli.js +100 -16
  86. package/dist/contracts/fill-treatments.js +47 -0
  87. package/dist/design-system/theme.d.ts +30 -300
  88. package/dist/design-system/theme.js +112 -90
  89. package/dist/dev-client/assets/index-DfEfrp6P.css +1 -0
  90. package/dist/dev-client/assets/index-bpMP0b0W.js +50 -0
  91. package/dist/dev-client/index.html +13 -0
  92. package/dist/eslint/is-client-file.d.ts +4 -0
  93. package/dist/eslint/rules/no-hex.js +78 -6
  94. package/dist/eslint.js +1 -1
  95. package/dist/index.d.ts +3 -2
  96. package/dist/index.js +2 -2
  97. package/dist/islands/detect.d.ts +16 -0
  98. package/dist/islands/detect.js +22 -0
  99. package/dist/islands/discover.d.ts +5 -0
  100. package/dist/islands/discover.js +19 -0
  101. package/dist/islands/esbuild-plugin.d.ts +13 -0
  102. package/dist/islands/esbuild-plugin.js +39 -0
  103. package/dist/islands/wrap.d.ts +11 -0
  104. package/dist/islands/wrap.js +48 -0
  105. package/dist/node_modules/@jridgewell/sourcemap-codec/dist/sourcemap-codec.js +77 -0
  106. package/dist/node_modules/magic-string/dist/magic-string.es.js +939 -0
  107. package/dist/package.js +1 -1
  108. package/dist/primitives/Image.js +1 -1
  109. package/dist/schema/fill-spec.d.ts +80 -590
  110. package/dist/schema/fixture-schema.d.ts +5 -81
  111. package/dist/schema/manifest.d.ts +39 -475
  112. package/dist/schema/resolve-section-ref.js +10 -0
  113. package/dist/schema/rows.js +10 -0
  114. package/dist/schema/section-nav.js +9 -0
  115. package/dist/schema/section-schema.d.ts +51 -437
  116. package/dist/schema/slot-types.d.ts +12 -16
  117. package/dist/ssr.d.ts +31 -0
  118. package/dist/ssr.js +46 -0
  119. package/dist/styles.css +31 -88
  120. package/docs/INDEX.md +12 -3
  121. package/docs/assets.md +128 -0
  122. package/docs/check.md +124 -0
  123. package/docs/dev.md +187 -0
  124. package/docs/eslint.md +18 -8
  125. package/docs/islands.md +5 -2
  126. package/docs/llms.txt +59 -9
  127. package/docs/new.md +27 -0
  128. package/docs/overview.md +57 -0
  129. package/docs/pack.md +46 -0
  130. package/docs/primitives.md +10 -6
  131. package/docs/quickstart.md +102 -0
  132. package/docs/recipes/INDEX.md +27 -0
  133. package/docs/recipes/bind-property-fact.md +58 -0
  134. package/docs/recipes/collection-slot.md +100 -0
  135. package/docs/recipes/fill-spec-decision.md +66 -0
  136. package/docs/recipes/fixture-states.md +68 -0
  137. package/docs/recipes/image-slot-crop.md +98 -0
  138. package/docs/recipes/interactive-island.md +100 -0
  139. package/docs/recipes/organize-section-folder.md +72 -0
  140. package/docs/recipes/prepare-submission.md +55 -0
  141. package/docs/recipes/second-template.md +49 -0
  142. package/docs/recipes/select-slot.md +59 -0
  143. package/docs/recipes/static-asset.md +103 -0
  144. package/docs/recipes/third-party-package.md +90 -0
  145. package/docs/rules/INDEX.md +14 -5
  146. package/docs/rules/bundle-binary-asset.md +83 -0
  147. package/docs/rules/bundle-incomplete.md +2 -2
  148. package/docs/rules/css-reason.md +3 -3
  149. package/docs/rules/fixtures-invalid.md +96 -0
  150. package/docs/rules/license-denied.md +11 -3
  151. package/docs/rules/manifest-invalid.md +99 -0
  152. package/docs/rules/no-bare-css-import.md +8 -8
  153. package/docs/rules/no-css-import-from-render-path.md +7 -8
  154. package/docs/rules/no-hex.md +76 -10
  155. package/docs/rules/no-templates.md +87 -0
  156. package/docs/rules/parse-error.md +76 -0
  157. package/docs/rules/schema-invalid.md +96 -0
  158. package/docs/rules/size-assets.md +88 -0
  159. package/docs/rules/size-island-bundle.md +123 -0
  160. package/docs/rules/size-section-css.md +19 -14
  161. package/docs/schema-system.md +28 -28
  162. package/docs/theme-and-css.md +157 -92
  163. package/docs/vocabulary.md +98 -0
  164. package/package.json +14 -11
  165. package/tsconfig.json +1 -1
@@ -0,0 +1,123 @@
1
+ ---
2
+ purpose: The template-kit/size-island-bundle authoring rule — what it enforces, why, and how to fix it.
3
+ status: living
4
+ related: [INDEX.md, size-renderer-bundle.md, size-section-css.md, server-vs-client.md]
5
+ updated: 2026-07-15
6
+ ---
7
+ # template-kit/size-island-bundle
8
+
9
+ > Each island's browser bundle must be **≤ 50 KB gzip**. The budget is not
10
+ > author-overridable, and it applies per island, not per section.
11
+
12
+ ## Rule
13
+
14
+ An island — a component whose file opens with the `"use client"` directive (see
15
+ [Server vs client](server-vs-client.md)) — ships to the browser as its own bundle,
16
+ separate from the section's server-rendered HTML. Each island is bundled with esbuild
17
+ and minified, with **React treated as external** (the page's runtime chunk supplies it
18
+ once, shared by every island on the page, and is not counted against any one of them).
19
+ The gzipped result must be **50 KB or less**.
20
+
21
+ A section can have more than one island. The budget is checked **per island**: a section
22
+ with three islands each at 40 KB passes; a section with one island at 60 KB fails, even
23
+ though the same section's other files are small.
24
+
25
+ The budget is **visible in `template-kit check`'s configuration and is not
26
+ author-overridable**. A template workspace cannot raise it, and there is no per-island
27
+ exemption.
28
+
29
+ ## Reason
30
+
31
+ An island is the part of the page that has to work on a phone before the visitor does
32
+ anything — a carousel, a filter, a form. Every one of its bytes is downloaded, parsed,
33
+ and executed on the client, on top of whatever the page's shared runtime already costs.
34
+ Unlike the section's server-rendered markup, which the browser only has to paint, an
35
+ island's code has to run.
36
+
37
+ The number is set to be generous, so it does not fight ordinary authoring — a real
38
+ interactive widget fits comfortably inside it. You hit the budget when something
39
+ **runaway** gets pulled into the client bundle:
40
+
41
+ - **a whole UI or icon library imported for one component**, which drags every export
42
+ into the bundle so the island can use a handful of them;
43
+ - **a heavy dependency that belongs on the server**, ported into the island because it
44
+ was easiest to reach for where the code was being written, not because the browser
45
+ needs it.
46
+
47
+ That is the shape of essentially every violation: not an island that grew, but a single
48
+ import that was more expensive than it looked.
49
+
50
+ ## Fix
51
+
52
+ Find the import that is not paying for itself, or narrow what ships to the browser at
53
+ all.
54
+
55
+ - **Import named symbols, never a namespace.** `import { ArrowRight } from "…"` is
56
+ tree-shakeable; `import * as Icons from "…"` is not — a namespace object has to contain
57
+ everything, so nothing can be dropped.
58
+ - **Prefer the package's narrow entry** if it offers one, instead of its default
59
+ "everything" bundle.
60
+ - **Move what doesn't need to be interactive out of the island.** If only a small part of
61
+ a component actually needs client-side behavior — an expand/collapse toggle inside an
62
+ otherwise static block — split the interactive part into its own small island and leave
63
+ the rest server-rendered. Server-rendered markup costs nothing against this budget.
64
+ - **Hand-write the residue.** If you use one function out of a large utility package, copy
65
+ the function instead of importing the package for it.
66
+
67
+ ### Before
68
+
69
+ ```tsx
70
+ // sections/gallery/Carousel.tsx
71
+ "use client";
72
+
73
+ import * as Icons from "lucide-react"; // ← the whole set: every icon, in this island's bundle
74
+ import { useState } from "react";
75
+
76
+ export default function Carousel({ slides }: { slides: { id: string; url: string }[] }) {
77
+ const [index, setIndex] = useState(0);
78
+ return (
79
+ <div>
80
+ <img src={slides[index]?.url} alt="" />
81
+ <button onClick={() => setIndex((i) => (i - 1 + slides.length) % slides.length)}>
82
+ <Icons.ChevronLeft aria-hidden />
83
+ </button>
84
+ <button onClick={() => setIndex((i) => (i + 1) % slides.length)}>
85
+ <Icons.ChevronRight aria-hidden />
86
+ </button>
87
+ </div>
88
+ );
89
+ }
90
+ ```
91
+
92
+ ### After
93
+
94
+ ```tsx
95
+ // sections/gallery/Carousel.tsx
96
+ "use client";
97
+
98
+ import { ChevronLeft, ChevronRight } from "lucide-react"; // ← two icons, and the bundler can drop the rest
99
+ import { useState } from "react";
100
+
101
+ export default function Carousel({ slides }: { slides: { id: string; url: string }[] }) {
102
+ const [index, setIndex] = useState(0);
103
+ return (
104
+ <div>
105
+ <img src={slides[index]?.url} alt="" />
106
+ <button onClick={() => setIndex((i) => (i - 1 + slides.length) % slides.length)}>
107
+ <ChevronLeft aria-hidden />
108
+ </button>
109
+ <button onClick={() => setIndex((i) => (i + 1) % slides.length)}>
110
+ <ChevronRight aria-hidden />
111
+ </button>
112
+ </div>
113
+ );
114
+ }
115
+ ```
116
+
117
+ ## See also
118
+
119
+ - [`size-renderer-bundle`](size-renderer-bundle.md) — the same budget on the section's
120
+ server-rendered bundle.
121
+ - [`size-section-css`](size-section-css.md) — the same budget on the section's stylesheet.
122
+ - [Server vs client](server-vs-client.md) — what makes a file an island in the first
123
+ place.
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  purpose: The template-kit/size-section-css authoring rule — what it enforces, why, and how to fix it.
3
3
  status: living
4
- related: [INDEX.md, size-renderer-bundle.md, ../theme-and-css.md]
5
- updated: 2026-07-14
4
+ related: [INDEX.md, size-renderer-bundle.md, size-assets.md, ../theme-and-css.md]
5
+ updated: 2026-07-15
6
6
  ---
7
7
  # template-kit/size-section-css
8
8
 
@@ -16,7 +16,7 @@ everything that lands in the layer:
16
16
 
17
17
  - the section's hand-written CSS, and
18
18
  - **every package stylesheet its module graph imports** — the `import "swiper/css"` in a
19
- component, an island, or a marked enhancer.
19
+ component or an island.
20
20
 
21
21
  The budget is **visible in `template-kit check`'s configuration and is not
22
22
  author-overridable**. A workspace cannot raise it, and there is no per-section exemption.
@@ -29,14 +29,15 @@ CSS blocks the first paint.
29
29
 
30
30
  "Including package CSS" is not a technicality; it is the failure this rule exists to catch.
31
31
  Your own stylesheet is almost never the problem — it is a few kilobytes of overrides. The
32
- package's is. And the reason it is invisible to you is the mechanism: a package stylesheet's
33
- `url()` references its icon sprites, its webfont are **inlined as `data:` URIs** into the
34
- compiled stylesheet. A 300 KB font file arrives in your published CSS without you ever
35
- having seen a 300 KB file. Nothing in your source looks large. Nothing in the package's
36
- `dist` listing looks alarming. The compiled layer is 400 KB.
32
+ package's is. A one-line `import "some-ui/dist.css"` can drag in thousands of rules for
33
+ components you never render resets, theme variants, every widget's styles and they all
34
+ land in your compiled layer. Nothing in your source looks large; the compiled layer is
35
+ 400 KB.
37
36
 
38
37
  So the number this check reports is frequently the first time anyone finds out what a
39
- one-line CSS import actually cost.
38
+ one-line CSS import actually cost. (What this budget does **not** count is asset bytes: a
39
+ `url()`-referenced webfont or icon file resolves to a cached URL and is weighed separately —
40
+ see [`size-assets`](size-assets.md).)
40
41
 
41
42
  ## Fix
42
43
 
@@ -45,10 +46,11 @@ Three moves, in order of how often they work:
45
46
  1. **Import the slimmest entry the package offers.** Most CSS-shipping packages have a core
46
47
  stylesheet plus opt-in modules, *and* a fat "everything" bundle that is the one you get
47
48
  from copying the quickstart. Take the core, plus only the modules you use.
48
- 2. **Cut the webfont.** If the package's stylesheet drags in a font for its icons, and you
49
- are using three of those icons, replace them with inline SVG — or with a family the page's
50
- theme already declares (`--tr-font-sans`, `--tr-font-serif`), which costs nothing
51
- because it is already loaded.
49
+ 2. **Drop the icon font.** If the package's stylesheet declares an icon font and you use
50
+ three of its glyphs, replace them with inline SVG — or with a family the template's theme
51
+ already declares in `fonts` (`--tr-font-<name>`), which costs nothing because it is
52
+ already loaded. (The font *file* is weighed under [`size-assets`](size-assets.md), but its
53
+ `@font-face` rules and every `.icon-*` class still land in this layer.)
52
54
  3. **Drop the package and hand-write the residue.** If you use one component from a large
53
55
  widget library, its stylesheet is paying for components you never render.
54
56
 
@@ -83,12 +85,15 @@ export default function Carousel({ slides }: { slides: { id: string; url: string
83
85
  ```
84
86
 
85
87
  The arrows and dots that came from the bundle's icon font are drawn as inline SVG in the
86
- component instead, so nothing pulls a webfont into the layer.
88
+ component instead, so the layer no longer carries that font's `@font-face` and icon-class
89
+ rules (and the font file no longer counts against [`size-assets`](size-assets.md) either).
87
90
 
88
91
  ## See also
89
92
 
90
93
  - [`size-renderer-bundle`](size-renderer-bundle.md) — the same budget on the section's
91
94
  JavaScript.
95
+ - [`size-assets`](size-assets.md) — the separate budget on the static asset *files*
96
+ (fonts, images) a stylesheet or component references.
92
97
  - [`no-bare-css-import`](no-bare-css-import.md) — why a package's stylesheet is imported from
93
98
  code and never `@import`ed from CSS.
94
99
  - [Theme and CSS](../theme-and-css.md) — the fonts and tokens the page already loads, which
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  purpose: The section authoring contract — schema.ts, fill-spec.ts, fixtures.ts, and the closed source/transform vocabulary.
3
3
  status: living
4
- related: [primitives.md, theme-and-css.md]
5
- updated: 2026-07-14
4
+ related: [primitives.md, theme-and-css.md, vocabulary.md]
5
+ updated: 2026-07-16
6
6
  ---
7
7
  # The schema system
8
8
 
@@ -32,22 +32,17 @@ from. Without it, `SectionProps` cannot tell a required slot from an optional on
32
32
  and your renderer props collapse to a useless widened shape.
33
33
 
34
34
  ```ts
35
- import { direct, type FrameConfig, type SectionProps, type SectionSchemaShape } from "@homepages/template-kit";
36
-
37
- export const HERO_FRAME = {
38
- breakpoint: 425,
39
- desktop: { aspect: 5 / 3 },
40
- } as const satisfies FrameConfig;
35
+ import { direct, type SectionProps, type SectionSchemaShape } from "@homepages/template-kit";
41
36
 
42
37
  export const schema = {
43
38
  meta: { displayName: "Hero" },
44
39
  slots: {
45
- headline: { type: "text", size: "medium", required: true, produced_by: "headline_text" },
46
- listing_status: { type: "select", required: false, source: direct("listing_intent") },
47
- hero_image: { type: "image", required: true, produced_by: "hero_image_assign", frame: HERO_FRAME },
40
+ hero_image: { type: "image", required: true, produced_by: "hero_image_assign", crop: { mode: "locked", aspect: 16 / 9 } },
41
+ headline: { type: "text", size: "medium", required: true, source: direct("address") },
42
+ blurb: { type: "text", size: "long", required: true, produced_by: "blurb_text" },
48
43
  },
49
44
  options: {
50
- align: { type: "enum", values: ["left", "center"], default: "left" },
45
+ align: { type: "enum", values: ["left", "center"], default: "center" },
51
46
  },
52
47
  } as const satisfies SectionSchemaShape;
53
48
 
@@ -65,14 +60,20 @@ binds it to a computed transform. Both take names from a **closed vocabulary**,
65
60
  a typo is a compile error at the call site — not a runtime surprise:
66
61
 
67
62
  ```ts
68
- source: direct("listing_intent") // ✅
69
- source: direct("listing_intentt") // ✗ compile error: not a SOURCE_FIELDS key
63
+ source: direct("address") // ✅
64
+ source: direct("addresss") // ✗ compile error: not a SOURCE_FIELDS key
70
65
  ```
71
66
 
72
- The vocabulary is the kit's, not yours. Read it off the exported tables
73
- `SOURCE_FIELDS` (facts `direct()` accepts), `TRANSFORMS` (`derived()`), and
74
- `SLOT_TYPE_DESCRIPTORS` (the slot types) or let your editor autocomplete it.
75
- Adding to it is a kit release, not a change you can make in a template.
67
+ The vocabulary is the kit's, not yours. Adding to it is a kit release, not a
68
+ change you can make in a template. See [the closed vocabulary](vocabulary.md)
69
+ for the sets and when to pick each; your editor autocompletes the exact fields.
70
+
71
+ A `select` slot binds the same way as any other type — here fact-bound rather
72
+ than AI-produced, with `values` declaring the closed set the fact resolves to:
73
+
74
+ ```ts
75
+ listing_status: { type: "select", required: false, source: direct("listing_intent"), values: ["for_sale", "for_rent"] },
76
+ ```
76
77
 
77
78
  ### Grouping slots into one editor card
78
79
 
@@ -104,7 +105,6 @@ import type { Props } from "./schema.js";
104
105
 
105
106
  export default function Hero({ slots, options, nav }: Props) {
106
107
  // headline is `string` — the schema says required: true
107
- // listing_status is `string | null` — required: false
108
108
  // options.align is `"left" | "center"` — the enum's declared values
109
109
  return (
110
110
  <Section id={nav.selfAnchor}>
@@ -153,12 +153,12 @@ export const fillSpec = {
153
153
  decisions: [
154
154
  { id: "hero_image_assign", type: "image-assign", produces: ["hero_image"], tag_query: "category:exterior" },
155
155
  {
156
- id: "headline_text",
156
+ id: "blurb_text",
157
157
  type: "text-block",
158
- produces: ["headline"],
159
- length_cap: 60,
160
- structure: "single_sentence",
161
- voice_prompt: "Confident premium-listing voice. Title Case.",
158
+ produces: ["blurb"],
159
+ length_cap: 400,
160
+ structure: "paragraph",
161
+ voice_prompt: "Warm, concrete paragraph a buyer would want to read.",
162
162
  },
163
163
  ],
164
164
  } as const satisfies FillSpecShape;
@@ -177,14 +177,14 @@ import type { FixtureModule } from "@homepages/template-kit";
177
177
  const fixtures: FixtureModule = {
178
178
  base: {
179
179
  slots: {
180
- headline: "A mansard Victorian in the heart of Jamaica Plain",
181
- listing_status: "for_sale",
180
+ headline: "101 Queensway, Jamaica Plain, MA 02130",
182
181
  hero_image: { photo_id: "p1", url: "https://example.com/a.jpg", alt: "Front elevation" },
182
+ blurb: "A bright corner home moments from the Emerald Necklace.",
183
183
  },
184
- options: { align: "left" },
184
+ options: { align: "center" },
185
185
  },
186
186
  states: {
187
- centered: { label: "Centered", options: { align: "center" } },
187
+ left: { label: "Left-aligned", options: { align: "left" } },
188
188
  },
189
189
  };
190
190
 
@@ -1,41 +1,170 @@
1
1
  ---
2
2
  purpose: The kit's two CSS entries, how a section's own CSS reaches the page, the TokenTheme a template supplies, and the breakpoint ladder.
3
3
  status: living
4
- related: [primitives.md, schema-system.md, eslint.md, rules/INDEX.md]
5
- updated: 2026-07-14
4
+ related: [primitives.md, schema-system.md, eslint.md, rules/INDEX.md, check.md]
5
+ updated: 2026-07-15
6
6
  ---
7
7
  # Theme and CSS
8
8
 
9
- The kit owns the **contract**: the Tailwind utility namespace (`text-ink`,
10
- `bg-surface-alt`, `rounded-md`), the breakpoint ladder, the reset gaps Tailwind's preflight leaves, and the section box
11
- model. Your template owns the **values** behind that namespace, declared once as a
12
- `TokenTheme`.
9
+ Your template owns its design system end to end — **the token names included**. You
10
+ declare the tokens your design actually has in a `TokenTheme`, and `template-kit theme`
11
+ compiles that one declaration into the Tailwind utilities that expose them. Declare a
12
+ colour named `lagoon` and `bg-lagoon` exists.
13
+
14
+ The kit owns only what is true of **every** template: the breakpoint ladder, the spacing
15
+ base unit, the motion defaults, the reset gaps Tailwind's preflight leaves, and the
16
+ section box model. It ships no colour, font, type, radius or shadow of its own, and there
17
+ is no shared file for you to edit to make a token of yours usable.
13
18
 
14
19
  Two CSS entries ship, and they are not interchangeable.
15
20
 
16
21
  | Entry | What it is | How it loads |
17
22
  |---|---|---|
18
- | `@homepages/template-kit/styles.css` | The Tailwind v4 `@theme inline` alias layer + the breakpoint ladder + `@source` registration of the kit's compiled JS | imported into **your Tailwind entry**, after Tailwind |
19
- | `@homepages/template-kit/base.css` | Cascade-layer order, the three reset gaps Tailwind's preflight leaves, `.tr-section`, and the motion tokens | its **own stylesheet** on the page — not part of the Tailwind entry |
23
+ | `@homepages/template-kit/styles.css` | The global Tailwind preamble: breakpoint ladder, `--spacing`, motion defaults, and `@source` registration of the kit's compiled JS | imported into **your Tailwind entry**, after Tailwind |
24
+ | `@homepages/template-kit/base.css` | Cascade-layer order, the three reset gaps Tailwind's preflight leaves, `.tr-section`, `.tr-image-frame`, and the motion tokens | its **own stylesheet** on the page — not part of the Tailwind entry |
20
25
 
21
- ## Import Tailwind exactly once
26
+ ## Your Tailwind entry
22
27
 
23
28
  ```css
24
29
  /* your CSS entry */
25
30
  @import "tailwindcss";
26
31
  @import "@homepages/template-kit/styles.css";
32
+ @import "./theme.css"; /* generated by `template-kit theme` from theme.ts */
27
33
  ```
28
34
 
29
- `styles.css` deliberately does **not** `@import "tailwindcss"` itself. Exactly one
30
- `@import "tailwindcss"` may exist per build graph — yours. Two double-emit Tailwind's
31
- preflight reset (`box-sizing` declared twice, several KB of duplicate base layer), and
32
- nothing errors to tell you.
35
+ Three constraints, all of them silent when broken:
36
+
37
+ - **Import Tailwind exactly once per build graph.** `styles.css` deliberately does not
38
+ `@import "tailwindcss"` itself yours does. Two double-emit Tailwind's preflight reset
39
+ (`box-sizing` declared twice, several KB of duplicate base layer), and nothing errors.
40
+ - **Your generated theme must be imported here, inside the Tailwind build graph.** It
41
+ contains an `@theme` block, which is a Tailwind directive: anywhere outside the graph it
42
+ is inert, and you get a stylesheet with no token utilities and no error explaining why.
43
+ - **Import it after `styles.css`**, so the kit's globals are in place before your tokens
44
+ land on top of them.
45
+
46
+ `styles.css` also carries an `@source` line registering the kit's own shipped `dist` for
47
+ Tailwind's class scan. That is how the utility classes baked into the kit's primitives
48
+ (`Image`'s `object-cover`, …) reach your compiled CSS; it also overrides Tailwind's default
49
+ exclusion of `node_modules`. You do not need to add a `@source` for the kit yourself.
50
+
51
+ ## `TokenTheme` — your template's design system
52
+
53
+ A template's `theme.ts` exports a `TokenTheme`: six open records of tokens, plus the
54
+ document defaults that dress the page. `theme.ts` is the file you write; run
55
+ `template-kit theme` to compile it into the `theme.css` your Tailwind entry imports
56
+ (see [Generating `theme.css`](#generating-themecss) below).
57
+
58
+ ```ts
59
+ import type { TokenTheme } from "@homepages/template-kit";
60
+
61
+ export const theme: TokenTheme = {
62
+ colors: { ink: "#111418", page: "#ffffff", "surface-alt": "#f4f4f5", lagoon: "#1c6e7d" },
63
+ fonts: { sans: '"Inter", system-ui, sans-serif', display: '"Canela", Georgia, serif' },
64
+ type: { base: "1rem", "2xl": "1.5rem", display: "clamp(2.5rem, 6vw, 4.5rem)" },
65
+ radii: { md: "6px", pill: "9999px" },
66
+ shadows: { card: "0 1px 2px rgb(0 0 0 / 0.06)" },
67
+ layout: { "container-max": "1280px", "header-height": "72px" },
68
+
69
+ // Optional font-loading block, emitted ahead of everything else.
70
+ fontFaces: '@import url("https://fonts.example/inter.css");',
71
+
72
+ // Which of YOUR tokens dress the document.
73
+ document: { font: "sans", color: "ink", background: "page", fontSize: "base" },
74
+ };
75
+ ```
76
+
77
+ Nothing is required to exist. Tailwind's own defaults survive underneath your tokens —
78
+ `text-2xl`, `rounded-lg`, `red-500` all still work — so a sparse theme is a working theme.
79
+ (The breakpoint ladder is the one exception: `sm:`, `xl:` and `2xl:` are deliberately
80
+ switched off. See below.)
81
+
82
+ ### Namespaces and the utilities they produce
83
+
84
+ Each record maps to one Tailwind theme namespace. A token's key is emitted **verbatim**
85
+ into both the custom property and the utility — no camelCase-to-kebab transform, so what
86
+ you type is what you get.
87
+
88
+ | Record | Custom property | Tailwind utilities | Example |
89
+ |---|---|---|---|
90
+ | `colors` | `--tr-color-<name>` | `bg-`, `text-`, `border-`, `ring-`, `fill-`, `stroke-`, … | `lagoon` → `bg-lagoon` |
91
+ | `fonts` | `--tr-font-<name>` | `font-<name>` | `display` → `font-display` |
92
+ | `type` | `--tr-text-<name>` | `text-<name>` | `2xl` → `text-2xl` |
93
+ | `radii` | `--tr-radius-<name>` | `rounded-<name>` | `pill` → `rounded-pill` |
94
+ | `shadows` | `--tr-shadow-<name>` | `shadow-<name>` | `card` → `shadow-card` |
95
+ | `layout` | `--tr-<name>` | *(none)* | `header-height` → `var(--tr-header-height)` |
96
+
97
+ `layout` has no Tailwind utility namespace, so its tokens get a `--tr-*` value and no
98
+ utility — reference them with `var()` from your own CSS.
99
+
100
+ ### Token names
101
+
102
+ Kebab-case: `[a-z0-9]` groups separated by single hyphens (`ink`, `ink-soft`, `bp2`).
103
+ `TokenNameSchema` is exported if you want to validate names yourself.
104
+
105
+ **One token, one utility.** Two utility names cannot share a token — if you want both
106
+ `bg-brand` and `bg-primary`, declare both.
107
+
108
+ ### The `document` block
109
+
110
+ Required. It names which of your **own** tokens dress the page — the `body` font-family,
111
+ colour, background, and font-size. `font` and `fontSize` resolve against `fonts` and
112
+ `type`; `color` and `background` resolve against `colors`.
113
+
114
+ Every reference is validated against your declared tokens, in its own group: a typo, or a
115
+ colour name where a font belongs, fails at parse rather than rendering an unthemed page in
116
+ silence.
117
+
118
+ ### Validation
119
+
120
+ `TokenThemeSchema` validates the object at runtime, and it is `.strict()` — an unknown
121
+ top-level key is an error rather than a silently-dead set of tokens.
122
+
123
+ ## Generating `theme.css`
33
124
 
34
- `styles.css` also carries `@source "./**/*.js"`, which registers the kit's own shipped
35
- `dist` for Tailwind's class scan. That is how the utility classes baked into the kit's
36
- primitives (`Image`'s `bg-surface-alt`, `object-cover`, …) reach your compiled CSS; it
37
- also overrides Tailwind's default exclusion of `node_modules`. You do not need to add a
38
- `@source` for the kit yourself.
125
+ `template-kit theme` loads `templates/<key>/theme.ts`, validates its `theme` export
126
+ against `TokenThemeSchema`, and writes `templates/<key>/theme.css` the file your
127
+ Tailwind entry imports.
128
+
129
+ ```bash
130
+ template-kit theme # infer the template from cwd, or the workspace's one template
131
+ template-kit theme <template> # generate one named template's theme.css, by its key
132
+ template-kit theme --all # generate every template in the workspace
133
+ ```
134
+
135
+ `theme.css` is generated — it opens with a header saying so, and it is not meant to be
136
+ hand-edited. Re-run `template-kit theme` after changing `theme.ts` and commit the
137
+ regenerated file alongside it.
138
+
139
+ A missing `theme.ts`, a `theme.ts` that doesn't export `theme`, or a `theme` that fails
140
+ `TokenThemeSchema` all fail the command with a message naming the file and the problem —
141
+ nothing is written in that case.
142
+
143
+ In order, the file it writes contains:
144
+
145
+ 1. your `fontFaces` block, if you set one;
146
+ 2. `@theme inline { --color-<name>: var(--tr-color-<name>); … }` — the alias layer, which
147
+ is what brings your token utilities into existence;
148
+ 3. `:root { --tr-color-<name>: <value>; … }` — the values behind those aliases;
149
+ 4. `body { … }` — your `document` defaults, written as `var()` indirections so a runtime
150
+ `:root` override still cascades to the page.
151
+
152
+ Deterministic: keys are sorted, so the same theme always content-hashes identically.
153
+
154
+ ## Colour in sections
155
+
156
+ A section carries no palette of its own, in server-rendered code or inside an island — the
157
+ `template-kit/no-hex` [lint rule](rules/no-hex.md) flags a hard-coded hex literal in a
158
+ colour context, and a CSS colour function anywhere, without exempting `"use client"` files
159
+ the way most of the preset's other rules do. Sections spend the theme's tokens; the theme
160
+ is where a colour is named.
161
+
162
+ For a colour the theme does not name — a hover, active, or pressed step, or a translucent
163
+ tint — derive it from the tokens that *are* named, with a `color-mix()` over theme-derived
164
+ arguments:
165
+ `hover:bg-[color-mix(in_srgb,var(--tr-color-primary)_80%,var(--tr-color-surface))]`. Such a
166
+ mix composes a colour out of the theme rather than stating one, and is the single colour
167
+ function `no-hex` allows — it carves out no other.
39
168
 
40
169
  ## How a section's CSS reaches the page
41
170
 
@@ -48,8 +177,8 @@ never write an import to make either happen:
48
177
  template's own hand-written stylesheet is collected the same way, into
49
178
  `@layer template`.
50
179
  2. **Package CSS, harvested from the render path.** The build also bundles
51
- `Renderer.tsx`'s module graph — and any component that carries `export const
52
- enhancer` — and pulls in whatever `.css` those imports resolve to. A bare specifier
180
+ `Renderer.tsx`'s module graph — every component and island it imports and pulls in
181
+ whatever `.css` those imports resolve to. A bare specifier
53
182
  (`import "swiper/css";`) resolves into `node_modules` and lands in the same
54
183
  `@layer sections`; that is the sanctioned way for a third-party package's
55
184
  stylesheet to reach the page.
@@ -70,12 +199,10 @@ Two constraints follow directly, and both are enforced by lint:
70
199
 
71
200
  A published page loads, in order:
72
201
 
73
- 1. **`base.css`** — layer order, the reset gaps, `.tr-section`, motion tokens.
74
- 2. **Your compiled theme CSS** — the `:root` block that defines the `--tr-*` values
75
- *and* the `body` block that applies them (font-family, color, background, size),
76
- both produced by `compileThemeToCss`.
77
- 3. **Your compiled Tailwind bundle** — `@import "tailwindcss"` + `styles.css` + your
78
- section markup's utilities.
202
+ 1. **`base.css`** — layer order, the reset gaps, `.tr-section`, `.tr-image-frame`, motion
203
+ tokens.
204
+ 2. **Your compiled Tailwind bundle** your entry: `@import "tailwindcss"` + the kit's
205
+ `styles.css` + your compiled theme + the utilities your section markup uses.
79
206
 
80
207
  The layer order `base.css` declares is:
81
208
 
@@ -96,70 +223,9 @@ not make: `font-smoothing`, `button { cursor: pointer }` (Tailwind v4 leaves but
96
223
 
97
224
  Ship `base.css` without a Tailwind entry on the page and you get an unreset page.
98
225
 
99
- `base.css` references **no theme token**. Your fonts and colors reach the document
100
- through `compileThemeToCss`, which emits both the `:root` block that defines your tokens
101
- and the `body` block that applies them. Nothing global is opinionated about your brand.
102
-
103
- ## `TokenTheme` — your template's values
104
-
105
- A template's `theme.ts` exports a `TokenTheme`. `compileThemeToCss` turns it into the
106
- `:root { --tr-*: … }` block that defines your tokens **and** the `body` block that
107
- applies them (font-family, color, background, size) to the document — together, the
108
- CSS the template's bundle ships. Deterministic (stable key order, palette keys
109
- sorted), so the same theme always content-hashes identically.
110
-
111
- ```ts
112
- import { compileThemeToCss, type TokenTheme } from "@homepages/template-kit";
113
-
114
- export const theme: TokenTheme = {
115
- colors: { ink: "#111418", background: "#ffffff", /* …every role, see below */ },
116
- palette: { "brand-gold": "#c8a248" }, // optional escape hatch
117
- fonts: {
118
- body: '"Inter", system-ui, sans-serif',
119
- heading: '"Canela", Georgia, serif',
120
- numeric: '"Inter", system-ui, sans-serif',
121
- label: '"Inter", system-ui, sans-serif',
122
- fontFaces: '@import url("https://fonts.example/inter.css");', // optional, emitted before :root
123
- },
124
- type: { xs: "0.75rem", sm: "0.875rem", base: "1rem", /* lg xl 2xl 3xl 4xl 5xl display */ },
125
- radii: { sm: "2px", md: "6px", lg: "12px", full: "9999px" },
126
- shadows: { sm: "…", md: "…", lg: "…" },
127
- layout: { containerMax: "1280px", containerPad: "1.5rem", headerHeight: "72px" },
128
- };
129
-
130
- const css = compileThemeToCss(theme);
131
- ```
132
-
133
- `TokenThemeSchema` validates it at runtime; the object is `.strict()`, so an unknown key
134
- is an error rather than a silently-dead token. Every scale is **complete or invalid** —
135
- there are no defaults to fall back on.
136
-
137
- ### Color roles
138
-
139
- The role keys are fixed. Each maps 1:1 to a `--tr-color-*` variable, and the Tailwind
140
- utility is named after **the variable**, not the role key — so `surfaceAlt` is
141
- `bg-surface-alt`, and `primaryStrong` is `bg-primary-darker`. Read the mapping off the
142
- exported `COLOR_ROLE_VARS` (and `FONT_ROLE_VARS`, `TYPE_STEP_VARS`, `RADIUS_VARS`,
143
- `SHADOW_VARS`, `LAYOUT_VARS` for the other scales), or let your editor autocomplete the
144
- `ColorRole` union:
145
-
146
- | Role key | CSS variable | Utility |
147
- |---|---|---|
148
- | `ink` `inkSoft` `inkMute` | `--tr-color-ink[-soft|-mute]` | `text-ink`, `text-ink-soft`, `text-ink-mute` |
149
- | `background` `surface` `surfaceAlt` | `--tr-color-background` / `-surface` / `-surface-alt` | `bg-surface-alt`, … |
150
- | `border` `borderStrong` | `--tr-color-border[-strong]` | `border-border-strong`, … |
151
- | `primary` `primaryFg` `primaryStrong` | `--tr-color-primary` / `-primary-fg` / `-primary-darker` | `bg-primary`, `text-primary-fg`, `bg-primary-darker` |
152
- | `accent` `accentFg` `accentStrong` | `--tr-color-accent` / `-accent-contrast` / `-accent-strong` | `bg-accent`, `text-accent-contrast`, `bg-accent-strong` |
153
- | `overlay` `overlayStrong` `overlayLight` | `--tr-color-overlay[-strong|-light]` | `bg-overlay`, … |
154
- | `darkBg` `darkFg` `darkFgMute` `darkBorder` | `--tr-color-footer-bg` / `-footer-fg` / `-footer-fg-mute` / `-footer-divider` | `bg-footer-bg`, `text-footer-fg`, `border-footer-divider`, … |
155
-
156
- `palette` is the open escape hatch: each key becomes `--tr-color-<key>`. Sections normally
157
- use the semantic roles — reach for a brand swatch only when no role honestly fits.
158
-
159
- A section carries no palette of its own, in server-rendered code or inside an island —
160
- the `template-kit/no-hex` [lint rule](eslint.md#universal--no-island-exemption) bans a
161
- hard-coded hex literal or CSS color function in a color context and does not exempt
162
- `"use client"` files, unlike most of the preset's other rules.
226
+ `base.css` references **no design token of yours** nothing global is opinionated about
227
+ your brand. Its one tintable surface is the `Image` frame's placeholder fill; see
228
+ [`Image`](primitives.md#image--the-defensive-image-primitive).
163
229
 
164
230
  ## The breakpoint ladder
165
231
 
@@ -191,8 +257,7 @@ memory — enough to make it evict a live WebGL map's layer on scroll, blanking
191
257
  they stop consuming that memory.
192
258
 
193
259
  The kit does **not** apply this for you — it has a real cost (below), and only you know
194
- whether your page needs it. If it does, add the rule to the same CSS entry that does
195
- `@import "tailwindcss"; @import "@homepages/template-kit/styles.css";` — the third
260
+ whether your page needs it. If it does, add the rule to your Tailwind entry the second
196
261
  stylesheet the page loads:
197
262
 
198
263
  ```css