@homepages/template-kit 0.0.1 → 0.2.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 (121) hide show
  1. package/CHANGELOG.md +251 -0
  2. package/README.md +77 -11
  3. package/dist/base.css +64 -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 +308 -0
  17. package/dist/design-system/theme.js +102 -0
  18. package/dist/eslint/is-client-file.js +79 -0
  19. package/dist/eslint/rules/image-bare-needs-reason.js +43 -0
  20. package/dist/eslint/rules/no-client-directive-in-contract.js +31 -0
  21. package/dist/eslint/rules/no-client-runtime-in-server.js +103 -0
  22. package/dist/eslint/rules/no-css-import-from-render-path.js +29 -0
  23. package/dist/eslint/rules/no-hex.js +116 -0
  24. package/dist/eslint/rules/no-inline-style.js +27 -0
  25. package/dist/eslint/rules/no-nondeterminism.js +48 -0
  26. package/dist/eslint/rules/no-raw-element.js +37 -0
  27. package/dist/eslint/rules/props-from-schema.js +37 -0
  28. package/dist/eslint/rules/serializable-island-props.js +112 -0
  29. package/dist/eslint/rules/slot-marker-literal.js +38 -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 +28 -12
  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/rules/registry.js +16 -0
  59. package/dist/schema/fill-spec.d.ts +692 -0
  60. package/dist/schema/fill-spec.js +98 -0
  61. package/dist/schema/fixture-schema.d.ts +143 -0
  62. package/dist/schema/fixture-schema.js +113 -0
  63. package/dist/schema/index.d.ts +16 -0
  64. package/dist/schema/manifest.d.ts +527 -0
  65. package/dist/schema/manifest.js +73 -0
  66. package/dist/schema/property-facts.d.ts +44 -0
  67. package/dist/schema/resolve-variants.d.ts +11 -0
  68. package/dist/schema/resolve-variants.js +15 -0
  69. package/dist/schema/runtime-values.d.ts +45 -0
  70. package/dist/schema/section-nav.d.ts +7 -0
  71. package/dist/schema/section-props.d.ts +69 -0
  72. package/dist/schema/section-schema.d.ts +533 -0
  73. package/dist/schema/section-schema.js +72 -0
  74. package/dist/schema/slot-types.d.ts +117 -0
  75. package/dist/schema/slot-types.js +181 -0
  76. package/dist/schema/source.d.ts +13 -0
  77. package/dist/schema/source.js +12 -0
  78. package/dist/schema/synthesize-nullable.d.ts +18 -0
  79. package/dist/schema/synthesize-nullable.js +47 -0
  80. package/dist/styles.css +131 -9
  81. package/docs/INDEX.md +7 -4
  82. package/docs/eslint.md +55 -0
  83. package/docs/islands.md +150 -0
  84. package/docs/llms.txt +33 -3
  85. package/docs/primitives.md +214 -0
  86. package/docs/rules/INDEX.md +58 -0
  87. package/docs/rules/audit-severity.md +91 -0
  88. package/docs/rules/bundle-incomplete.md +117 -0
  89. package/docs/rules/css-reason.md +94 -0
  90. package/docs/rules/determinism-drift.md +112 -0
  91. package/docs/rules/image-bare-needs-reason.md +117 -0
  92. package/docs/rules/license-denied.md +106 -0
  93. package/docs/rules/lockfile-missing.md +68 -0
  94. package/docs/rules/lockfile-stale.md +88 -0
  95. package/docs/rules/missing-slot-marker.md +140 -0
  96. package/docs/rules/no-bare-css-import.md +128 -0
  97. package/docs/rules/no-client-directive-in-contract.md +111 -0
  98. package/docs/rules/no-client-runtime-in-server.md +143 -0
  99. package/docs/rules/no-css-import-from-render-path.md +118 -0
  100. package/docs/rules/no-hex.md +97 -0
  101. package/docs/rules/no-inline-style.md +121 -0
  102. package/docs/rules/no-nondeterminism.md +100 -0
  103. package/docs/rules/no-raw-element.md +100 -0
  104. package/docs/rules/props-from-schema.md +119 -0
  105. package/docs/rules/render-invariant.md +165 -0
  106. package/docs/rules/serializable-island-props.md +146 -0
  107. package/docs/rules/server-vs-client.md +105 -0
  108. package/docs/rules/sidebar-order.md +109 -0
  109. package/docs/rules/single-react.md +97 -0
  110. package/docs/rules/size-renderer-bundle.md +109 -0
  111. package/docs/rules/size-section-css.md +95 -0
  112. package/docs/rules/slot-group-marker.md +127 -0
  113. package/docs/rules/slot-marker-literal.md +148 -0
  114. package/docs/rules/typecheck.md +103 -0
  115. package/docs/rules/unlayered-fence.md +113 -0
  116. package/docs/schema-system.md +240 -0
  117. package/docs/theme-and-css.md +225 -0
  118. package/package.json +30 -6
  119. package/tsconfig.base.json +16 -0
  120. package/tsconfig.json +2 -13
  121. package/dist/src-dZr5N30y.js +0 -35
@@ -0,0 +1,113 @@
1
+ ---
2
+ purpose: The template-kit/unlayered-fence authoring rule — what it enforces, why, and how to fix it.
3
+ status: living
4
+ related: [INDEX.md, ../theme-and-css.md]
5
+ updated: 2026-07-14
6
+ ---
7
+ # template-kit/unlayered-fence
8
+
9
+ > **This is not "you forgot to layer your CSS."** Everything is layered by default — the
10
+ > build does it for you. This rule governs the *escape hatch* from layering: an
11
+ > `/* unlayered: … */` fence must give a reason and must be closed.
12
+
13
+ ## Rule
14
+
15
+ Your hand-written CSS is wrapped in a cascade layer by the build. You do not write
16
+ `@layer` yourself, and there is nothing to forget.
17
+
18
+ The **fence** opts a run of rules *out* of that wrapping, hoisting them above every layer:
19
+
20
+ ```css
21
+ /* unlayered: <reason> */
22
+ …rules that must beat every layer…
23
+ /* endunlayered */
24
+ ```
25
+
26
+ Two things are checked, both under this id:
27
+
28
+ 1. **Every opener carries a non-empty reason.** A bare `/* unlayered: */` is a violation.
29
+ 2. **Openers and closers balance.** An unterminated fence is a violation — it would swallow
30
+ the whole rest of the file out of the layer, which is never what anyone meant.
31
+
32
+ ## Reason
33
+
34
+ Cascade layers have one rule that decides everything here: **an unlayered rule beats a
35
+ layered one, regardless of specificity.** Not "usually" — always. That is what layers are
36
+ for, and it is why the platform layers your CSS: inside the layer order (`theme, base,
37
+ components, sections, template, utilities`) a Tailwind utility can always override a
38
+ section's stylesheet, and a section's stylesheet can never leak past its layer to beat
39
+ someone else's utility.
40
+
41
+ So the hatch exists for exactly one situation: **a third-party library injects an unlayered
42
+ rule into the document at runtime** — a map SDK writing a `<style>` element when it mounts,
43
+ say — and that rule beats your layered override no matter how specific you make it. The
44
+ only thing that can beat it is a rule that is also unlayered. Nothing else qualifies. If
45
+ your rule merely needs to beat *your own* CSS, or a Tailwind utility, it belongs in a layer
46
+ and you are reaching for the wrong tool.
47
+
48
+ Which is why the reason is mandatory. A fence with no reason is a rule that outranks every
49
+ layer on the page for reasons the next author cannot reconstruct — they cannot tell whether
50
+ it is load-bearing or whether someone was fighting a specificity problem with a hammer. And
51
+ an unbalanced fence silently promotes every rule below it, so a stylesheet that behaved
52
+ yesterday starts beating the utilities that were supposed to override it.
53
+
54
+ ## Fix
55
+
56
+ Close every fence with `/* endunlayered */`, and give every opener a reason that **names the
57
+ runtime-injected rule it has to beat**. A short phrase is enough — it only has to be
58
+ non-empty and specific about which rule it's beating, not a full sentence. If it does not
59
+ have to beat one, delete the fence: layered is the default, and the default is right.
60
+
61
+ The Before/After below both open with a `/* css-reason: … */` comment. That line has
62
+ nothing to do with fencing — it satisfies a separate rule, [`css-reason`](css-reason.md),
63
+ which every hand-written stylesheet must open with. It's shown here because real section
64
+ CSS carries both; this rule's own grammar starts at `/* unlayered: */`.
65
+
66
+ ### Before
67
+
68
+ ```css
69
+ /* css-reason: overrides the map SDK's injected control chrome. */
70
+
71
+ /* unlayered: */
72
+ .maplibregl-ctrl-group button {
73
+ background: var(--tr-color-surface);
74
+ border-radius: var(--tr-radius-sm);
75
+ }
76
+
77
+ .tr-map-legend {
78
+ padding: 0.5rem;
79
+ }
80
+ ```
81
+
82
+ Two violations at once: the reason is empty, and the fence is never closed — so
83
+ `.tr-map-legend` is hoisted out of the layer too, where it now outranks every utility a
84
+ future author might try to override it with.
85
+
86
+ ### After
87
+
88
+ ```css
89
+ /* css-reason: overrides the map SDK's control chrome — the SDK builds those nodes after
90
+ hydration, so Tailwind's scanner never sees a class to compile. */
91
+
92
+ /* unlayered: the map SDK injects its control styles as an unlayered <style> element at
93
+ runtime; a layered rule cannot beat it at any specificity. */
94
+ .maplibregl-ctrl-group button {
95
+ background: var(--tr-color-surface);
96
+ border-radius: var(--tr-radius-sm);
97
+ }
98
+ /* endunlayered */
99
+
100
+ /* Our own DOM — layered, like everything else. */
101
+ .tr-map-legend {
102
+ padding: 0.5rem;
103
+ }
104
+ ```
105
+
106
+ The fence now wraps exactly the rules that need to escape, and `.tr-map-legend` is back in
107
+ the layer where a utility can still override it.
108
+
109
+ ## See also
110
+
111
+ - [Theme and CSS](../theme-and-css.md) — the layer order, and why utilities come last.
112
+ - [`css-reason`](css-reason.md) — the comment every hand-written stylesheet opens with; the
113
+ fence reason is the same discipline, one level in.
@@ -0,0 +1,240 @@
1
+ ---
2
+ purpose: The section authoring contract — schema.ts, fill-spec.ts, fixtures.ts, and the closed source/transform vocabulary.
3
+ status: living
4
+ related: [primitives.md, theme-and-css.md]
5
+ updated: 2026-07-14
6
+ ---
7
+ # The schema system
8
+
9
+ Everything here is imported from the package root:
10
+
11
+ ```ts
12
+ import { direct, type SectionProps, type SectionSchemaShape } from "@homepages/template-kit";
13
+ ```
14
+
15
+ There are no subpath imports for the authoring API — one specifier, on purpose.
16
+
17
+ A section is **four files in one folder**. Three of them are declarations the
18
+ platform reads; the fourth is your React.
19
+
20
+ | File | Declares | Key type |
21
+ |---|---|---|
22
+ | `schema.ts` | The slots and options the section has | `SectionSchemaShape` |
23
+ | `fill-spec.ts` | How AI fills those slots | `FillSpecShape` |
24
+ | `fixtures.ts` | Sample content for local preview | `FixtureModule` |
25
+ | `Renderer.tsx` | The markup | `SectionProps<typeof schema>` |
26
+
27
+ ## `schema.ts` — what the section contains
28
+
29
+ Declare the schema `as const satisfies SectionSchemaShape`. **The `as const` is
30
+ load-bearing**: it preserves the literal types that everything downstream infers
31
+ from. Without it, `SectionProps` cannot tell a required slot from an optional one
32
+ and your renderer props collapse to a useless widened shape.
33
+
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;
41
+
42
+ export const schema = {
43
+ meta: { displayName: "Hero" },
44
+ 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 },
48
+ },
49
+ options: {
50
+ align: { type: "enum", values: ["left", "center"], default: "left" },
51
+ },
52
+ } as const satisfies SectionSchemaShape;
53
+
54
+ export type Props = SectionProps<typeof schema>;
55
+ ```
56
+
57
+ A **slot** is content: something a user can edit or AI can fill. An **option** is
58
+ behavior: a switch the section reads to render differently. If a user would type
59
+ into it, it is a slot.
60
+
61
+ ### Binding a slot to a property fact
62
+
63
+ `direct(field)` binds a slot to a known fact about the property; `derived(name)`
64
+ binds it to a computed transform. Both take names from a **closed vocabulary**, so
65
+ a typo is a compile error at the call site — not a runtime surprise:
66
+
67
+ ```ts
68
+ source: direct("listing_intent") // ✅
69
+ source: direct("listing_intentt") // ✗ compile error: not a SOURCE_FIELDS key
70
+ ```
71
+
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.
76
+
77
+ ### Grouping slots into one editor card
78
+
79
+ `meta.groups` collapses several slots into a single card in the editor sidebar. Each
80
+ group declares an `id`, a `label`, and its `members` — a bare slot id is a full-width
81
+ row, a `[a, b]` tuple is one 2-up row:
82
+
83
+ ```ts
84
+ meta: {
85
+ displayName: "Contact",
86
+ groups: [
87
+ { id: "agent", label: "Agent", members: ["agent_name", ["agent_email", "agent_phone"]] },
88
+ ],
89
+ },
90
+ ```
91
+
92
+ The renderer must match this by wrapping the members in `<SlotGroup id="agent">` — see
93
+ [Contract primitives](primitives.md#slotgroup--slots-edited-as-one-unit).
94
+
95
+ ## `Renderer.tsx` — props derived, never hand-written
96
+
97
+ Never hand-write your prop type. Derive it, and the schema stays the single
98
+ source of truth:
99
+
100
+ ```tsx
101
+ import { Section, Slot } from "@homepages/template-kit";
102
+
103
+ import type { Props } from "./schema.js";
104
+
105
+ export default function Hero({ slots, options, nav }: Props) {
106
+ // headline is `string` — the schema says required: true
107
+ // listing_status is `string | null` — required: false
108
+ // options.align is `"left" | "center"` — the enum's declared values
109
+ return (
110
+ <Section id={nav.selfAnchor}>
111
+ <Slot id="headline" as="h1" textLeaf>
112
+ <span>{slots.headline}</span>
113
+ </Slot>
114
+ </Section>
115
+ );
116
+ }
117
+ ```
118
+
119
+ `SectionProps` reads the schema literal and resolves each slot to its runtime
120
+ value shape: a required `text` becomes `string`, an optional one `string | null`,
121
+ an `image` becomes `ImageValue`, an `image_collection` an array, and so on. A slot
122
+ you did not declare is a compile error to read.
123
+
124
+ An `ImageValue` splits into fields a renderer **reads** and fields it only **carries**.
125
+ Read `url`, `alt`, `responsive`, and `mobile`, and pass them to
126
+ [`Image`](primitives.md#image--the-defensive-image-primitive). Ignore `rect`, `ar`, and
127
+ `crop_key`: they are the editor's re-edit state (so its cropper can reopen at the last
128
+ crop) and the provenance of the already-cropped `url`. **Framing is baked into the asset
129
+ the platform serves, never applied at render time** — an uncropped image is a centered
130
+ `object-cover` over the original and a cropped one is a pre-cropped derivative, so there
131
+ is no framing for a renderer to apply. A slot's `crop` config steers the editor's cropper,
132
+ not your markup.
133
+
134
+ The markup a section is *required* to emit — the section root, the editor's slot
135
+ markers, images — comes from the kit's five primitives; everything else is your own
136
+ JSX. See [Contract primitives](primitives.md).
137
+
138
+ Renderers must be **pure functions of props** — no fetches, no globals, no
139
+ `useEffect` for data, no `Date.now()` / `Math.random()`. They are rendered to
140
+ static markup by the publisher and re-mounted constantly by the editor.
141
+
142
+ ## `fill-spec.ts` — how AI fills the slots
143
+
144
+ Each decision `produces` one or more slot names. A slot filled by AI names its
145
+ decision in `produced_by`; a slot bound with `direct()` needs no decision, because
146
+ the fact is baked in at fill time.
147
+
148
+ ```ts
149
+ import type { FillSpecShape } from "@homepages/template-kit";
150
+
151
+ export const fillSpec = {
152
+ reads: [],
153
+ decisions: [
154
+ { id: "hero_image_assign", type: "image-assign", produces: ["hero_image"], tag_query: "category:exterior" },
155
+ {
156
+ id: "headline_text",
157
+ type: "text-block",
158
+ produces: ["headline"],
159
+ length_cap: 60,
160
+ structure: "single_sentence",
161
+ voice_prompt: "Confident premium-listing voice. Title Case.",
162
+ },
163
+ ],
164
+ } as const satisfies FillSpecShape;
165
+ ```
166
+
167
+ ## `fixtures.ts` — content for local preview
168
+
169
+ `base` is one fully-specified baseline: every required slot, with a real value.
170
+ `states` adds named scenarios that override part of it — use them for the
171
+ structural cases your section is designed to handle (a missing logo, a long
172
+ headline, an empty list).
173
+
174
+ ```ts
175
+ import type { FixtureModule } from "@homepages/template-kit";
176
+
177
+ const fixtures: FixtureModule = {
178
+ base: {
179
+ slots: {
180
+ headline: "A mansard Victorian in the heart of Jamaica Plain",
181
+ listing_status: "for_sale",
182
+ hero_image: { photo_id: "p1", url: "https://example.com/a.jpg", alt: "Front elevation" },
183
+ },
184
+ options: { align: "left" },
185
+ },
186
+ states: {
187
+ centered: { label: "Centered", options: { align: "center" } },
188
+ },
189
+ };
190
+
191
+ export default fixtures;
192
+ ```
193
+
194
+ ### Build on the golden property, not on lorem ipsum
195
+
196
+ Preview is only as honest as the data behind it, so the kit ships a realistic dataset to
197
+ build fixtures from: a 12-unit building with long unit labels, a reserved unit and a
198
+ missing price — the cases that actually break a layout.
199
+
200
+ ```ts
201
+ import { allPhotos, sampleProperty, sampleContacts, samplePois, sampleAmenities, mkResponsive } from "@homepages/template-kit";
202
+ ```
203
+
204
+ | Export | What it is |
205
+ |---|---|
206
+ | `sampleProperty` | The full `PropertyFacts` bundle — address, units, contacts, coordinates |
207
+ | `allPhotos` | The gallery/hero photo pool (`PhotoFixture[]`) |
208
+ | `sampleContacts` | Attached agent cards (`ContactFixture[]`) |
209
+ | `samplePois` | Neighborhood points of interest with real coordinates (`PoiFixture[]`) |
210
+ | `sampleAmenities` | Curated amenity rows (`AmenityItem[]`) |
211
+ | `sampleHeroSlots` | A ready-made slot bundle for a hero-shaped section |
212
+ | `mkResponsive(url, width, height)` | Synthesizes a deterministic `ResponsiveImage` descriptor so `<Image responsive>` can be previewed and snapshotted |
213
+
214
+ ## Variants — rendering differently based on the facts
215
+
216
+ A variant axis picks a case from the property's facts, before any content is
217
+ filled. Declare the axis in `meta.variants` with its `cases`, a `default` for when
218
+ facts are missing, and a pure `select` function. Your renderer receives the
219
+ resolved case on `props.variants`.
220
+
221
+ ```ts
222
+ meta: {
223
+ displayName: "Floorplan",
224
+ variants: {
225
+ layout: {
226
+ cases: ["single", "multi"],
227
+ default: "single",
228
+ select: (input) => (input.units.length > 1 ? "multi" : "single"),
229
+ },
230
+ },
231
+ },
232
+ ```
233
+
234
+ `select` receives a `TransformInput` and must be **pure** — it runs inside the
235
+ published bundle. A case it returns that is not in `cases` falls back to `default`.
236
+
237
+ ## `manifest.json` — composing sections into a template
238
+
239
+ The template's `manifest.json` lists the ordered section instances that make up a
240
+ page. It is validated against the exported `TemplateManifest` schema.
@@ -0,0 +1,225 @@
1
+ ---
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
+ status: living
4
+ related: [primitives.md, schema-system.md, eslint.md, rules/INDEX.md]
5
+ updated: 2026-07-14
6
+ ---
7
+ # Theme and CSS
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`.
13
+
14
+ Two CSS entries ship, and they are not interchangeable.
15
+
16
+ | Entry | What it is | How it loads |
17
+ |---|---|---|
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 |
20
+
21
+ ## Import Tailwind exactly once
22
+
23
+ ```css
24
+ /* your CSS entry */
25
+ @import "tailwindcss";
26
+ @import "@homepages/template-kit/styles.css";
27
+ ```
28
+
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.
33
+
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.
39
+
40
+ ## How a section's CSS reaches the page
41
+
42
+ A section's compiled CSS layer is assembled from two sources, both automatic — you
43
+ never write an import to make either happen:
44
+
45
+ 1. **Hand-written CSS, collected by path.** Every `.css` file inside the section's
46
+ folder — `styles.css` and any other stylesheet nested under it — is picked up by
47
+ walking the folder, wherever it sits, and concatenated into `@layer sections`. A
48
+ template's own hand-written stylesheet is collected the same way, into
49
+ `@layer template`.
50
+ 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
53
+ (`import "swiper/css";`) resolves into `node_modules` and lands in the same
54
+ `@layer sections`; that is the sanctioned way for a third-party package's
55
+ stylesheet to reach the page.
56
+
57
+ Two constraints follow directly, and both are enforced by lint:
58
+
59
+ - **Don't re-import your own stylesheet.** It is already collected by path —
60
+ importing it again from a `.ts`/`.tsx` file only double-emits it, and can smuggle a
61
+ stray `@layer` statement into a nested layer.
62
+ See [`no-css-import-from-render-path`](rules/no-css-import-from-render-path.md).
63
+ - **`@import` never works inside hand-written CSS, package or local.** An `@import`
64
+ landing inside `@layer sections` / `@layer template` is invalid CSS, and the
65
+ browser drops it silently rather than erroring — the package's styling is simply
66
+ missing from the published page. Reach a package's stylesheet from code instead.
67
+ See [`no-bare-css-import`](rules/no-bare-css-import.md).
68
+
69
+ ## The page's stylesheets
70
+
71
+ A published page loads, in order:
72
+
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.
79
+
80
+ The layer order `base.css` declares is:
81
+
82
+ ```css
83
+ @layer theme, base, components, sections, template, utilities;
84
+ ```
85
+
86
+ Utilities come last, so a Tailwind utility always beats the reset's element defaults.
87
+
88
+ ### base.css requires a Tailwind entry
89
+
90
+ `base.css` does **not** ship a reset. Your entry's `@import "tailwindcss"` brings
91
+ Tailwind's preflight, and preflight is the reset — box-sizing, margins, list-style, the
92
+ button and anchor resets. `base.css` carries only the three declarations preflight does
93
+ not make: `font-smoothing`, `button { cursor: pointer }` (Tailwind v4 leaves buttons at
94
+ `cursor: default`), and `svg { max-width: 100% }` (preflight caps only `img` and
95
+ `video`).
96
+
97
+ Ship `base.css` without a Tailwind entry on the page and you get an unreset page.
98
+
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.
163
+
164
+ ## The breakpoint ladder
165
+
166
+ Eight canonical widths. 350 is the mobile-first **base** (no prefix); the other seven are
167
+ named breakpoints:
168
+
169
+ | Width | Prefix |
170
+ |---|---|
171
+ | 350 | *(base — no prefix)* |
172
+ | 390 | `bp390:` |
173
+ | 425 | `bp425:` |
174
+ | 512 | `bp512:` |
175
+ | 768 | `md:` |
176
+ | 1024 | `lg:` |
177
+ | 1440 | `bp1440:` |
178
+ | 1920 | `bp1920:` |
179
+
180
+ `md` and `lg` keep Tailwind's own names and values. Tailwind's off-ladder defaults
181
+ (`sm:`, `xl:`, `2xl:`) are reset to `initial` and **do not exist** — a section cannot come
182
+ to depend on a width nobody verifies it at. The same widths are exported as
183
+ `VERIFICATION_WIDTHS`, which is the set your section is screenshotted and verified
184
+ against. A swap-heavy section may still write a raw `@media` query at an off-ladder width.
185
+
186
+ ## Recipe: `content-visibility` on long, image-heavy pages
187
+
188
+ A page that stacks many image-heavy sections can exhaust the browser's GPU/raster
189
+ memory — enough to make it evict a live WebGL map's layer on scroll, blanking the map.
190
+ `content-visibility: auto` fixes this by letting off-screen sections skip rendering, so
191
+ they stop consuming that memory.
192
+
193
+ 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
196
+ stylesheet the page loads:
197
+
198
+ ```css
199
+ @layer template {
200
+ .tr-root .tr-section:not(header):not(.my-map-section) {
201
+ content-visibility: auto;
202
+ contain-intrinsic-size: auto 600px;
203
+ }
204
+ }
205
+ ```
206
+
207
+ Layer placement matters: inside `@layer template`, a Tailwind utility (`@layer
208
+ utilities`, which comes later in the layer order) can always override this rule on a
209
+ given section. Left unlayered, it would beat every layer instead, including
210
+ utilities. Neither collides today — no Tailwind utility sets `content-visibility` —
211
+ but wrapping it in `@layer template` keeps that guarantee explicit rather than
212
+ accidental.
213
+
214
+ `.tr-root` is set on `<body>` at publish time and is **absent in the editor**, so the rule
215
+ is published-only — and so are its consequences. You will not see them in preview.
216
+ `contain-intrinsic-size: auto` remembers each section's rendered height, avoiding scroll jank.
217
+
218
+ **The cost.** `content-visibility` implies `contain: paint`, which **clips descendants to
219
+ the section box**. So:
220
+
221
+ - A full-screen overlay — a lightbox, a gallery, a modal — must be appended to
222
+ `document.body`, never rendered inside the section. Inside, it gets clipped.
223
+ - A section that must stay painted while off-screen (a live map), or whose dropdowns
224
+ escape its box, must be excluded — add its class to the `:not()` list, as
225
+ `.my-map-section` is above.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homepages/template-kit",
3
- "version": "0.0.1",
3
+ "version": "0.2.0",
4
4
  "description": "Authoring kit for HomePages marketing-section templates: schema system, contract primitives, theme tokens, and the template-kit CLI.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -17,7 +17,9 @@
17
17
  "files": [
18
18
  "dist",
19
19
  "docs",
20
- "tsconfig.json"
20
+ "tsconfig.json",
21
+ "tsconfig.base.json",
22
+ "CHANGELOG.md"
21
23
  ],
22
24
  "bin": {
23
25
  "template-kit": "dist/cli.js"
@@ -27,25 +29,40 @@
27
29
  "types": "./dist/index.d.ts",
28
30
  "default": "./dist/index.js"
29
31
  },
32
+ "./fixtures": {
33
+ "types": "./dist/fixtures.d.ts",
34
+ "default": "./dist/fixtures.js"
35
+ },
36
+ "./island-runtime": {
37
+ "types": "./dist/island-runtime.d.ts",
38
+ "default": "./dist/island-runtime.js"
39
+ },
40
+ "./browser": {
41
+ "types": "./dist/browser.d.ts",
42
+ "default": "./dist/browser.js"
43
+ },
30
44
  "./eslint": {
31
45
  "types": "./dist/eslint.d.ts",
32
46
  "default": "./dist/eslint.js"
33
47
  },
34
48
  "./styles.css": "./dist/styles.css",
35
- "./tsconfig": "./tsconfig.json",
49
+ "./base.css": "./dist/base.css",
50
+ "./tsconfig": "./tsconfig.base.json",
36
51
  "./package.json": "./package.json"
37
52
  },
38
53
  "sideEffects": [
39
54
  "*.css"
40
55
  ],
41
56
  "scripts": {
57
+ "prepack": "npm run build",
42
58
  "build": "tsdown",
43
59
  "typecheck": "tsc --noEmit",
44
60
  "lint": "eslint .",
45
- "test": "node --import tsx --test src/*.test.tsx src/cli/*.test.ts",
46
- "lint:pkg": "publint --strict && attw --pack . --profile esm-only --exclude-entrypoints styles.css",
61
+ "test": "node --import tsx --test 'src/**/*.test.ts' 'src/**/*.test.tsx'",
62
+ "lint:pkg": "publint --strict && attw --pack . --profile esm-only --exclude-entrypoints styles.css base.css",
47
63
  "verify:consumer": "node scripts/verify-consumer.mjs",
48
- "check": "npm run typecheck && npm run lint && npm run build && npm run test && npm run lint:pkg && npm run verify:consumer",
64
+ "size:islands": "node scripts/check-island-runtime-size.mjs",
65
+ "check": "npm run typecheck && npm run lint && npm run build && npm run size:islands && npm run test && npm run lint:pkg && npm run verify:consumer",
49
66
  "changeset": "changeset",
50
67
  "release": "changeset publish"
51
68
  },
@@ -64,10 +81,13 @@
64
81
  "@changesets/cli": "^2.29.8",
65
82
  "@eslint/js": "^9.39.4",
66
83
  "@tailwindcss/cli": "^4.3.0",
84
+ "@types/jsdom": "^21.1.7",
67
85
  "@types/node": "^20.17.10",
68
86
  "@types/react": "^19.0.0",
69
87
  "@types/react-dom": "^19.0.0",
88
+ "esbuild": "^0.28.1",
70
89
  "eslint": "^9.39.4",
90
+ "jsdom": "^26.1.0",
71
91
  "publint": "^0.3.21",
72
92
  "react": "^19.0.0",
73
93
  "react-dom": "^19.0.0",
@@ -76,5 +96,9 @@
76
96
  "tsx": "^4.19.2",
77
97
  "typescript": "^5.7.2",
78
98
  "typescript-eslint": "^8.60.1"
99
+ },
100
+ "dependencies": {
101
+ "@typescript-eslint/parser": "^8.60.1",
102
+ "zod": "^3.23.8"
79
103
  }
80
104
  }
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "lib": ["ES2022", "DOM"],
7
+ "jsx": "react-jsx",
8
+ "strict": true,
9
+ "noUncheckedIndexedAccess": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "isolatedModules": true,
13
+ "resolveJsonModule": true,
14
+ "forceConsistentCasingInFileNames": true
15
+ }
16
+ }