@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,88 @@
1
+ ---
2
+ purpose: The template-kit/lockfile-stale authoring rule — what it enforces, why, and how to fix it.
3
+ status: living
4
+ related: [INDEX.md, lockfile-missing.md]
5
+ updated: 2026-07-14
6
+ ---
7
+ # template-kit/lockfile-stale
8
+
9
+ > The lockfile must satisfy `package.json`. If `npm ci` would fail, this fails first — on
10
+ > your machine, where you can still do something about it.
11
+
12
+ ## Rule
13
+
14
+ `package-lock.json` must be consistent with `package.json`: every dependency the manifest
15
+ asks for is present in the lockfile, at a version its range accepts.
16
+
17
+ Like [`lockfile-missing`](lockfile-missing.md), this is a property of the **workspace**, not
18
+ of any one template, and is reported once.
19
+
20
+ ## Reason
21
+
22
+ Your submission is installed with **`npm ci`**, which does not resolve versions — it
23
+ installs exactly what the lockfile pins, and **aborts** if the lockfile and the manifest
24
+ disagree. A lockfile that cannot reproduce the tree is not a slightly-out-of-date
25
+ convenience file; it is an install that does not happen.
26
+
27
+ Checking it here means you see it now. Otherwise the first thing to notice is the install
28
+ on our side, and the first thing you hear is that your submission was rejected.
29
+
30
+ There are two ways authors get here, and both are ordinary:
31
+
32
+ - **Hand-editing a version in `package.json`** — bumping `^11.0.0` to `^12.0.0` in the
33
+ editor and never running an install.
34
+ - **Merging a branch** and keeping only one side of the lockfile — resolving the conflict by
35
+ taking `package.json` from one branch and `package-lock.json` from the other. The two now
36
+ describe different trees.
37
+
38
+ ## Fix
39
+
40
+ Run `npm install` — it resyncs the lockfile to the manifest — and commit the result. Do not
41
+ hand-edit `package-lock.json`; regenerate it. If you got here from a merge conflict, take
42
+ `package.json` first, then let `npm install` rebuild the lockfile from it.
43
+
44
+ ### Before
45
+
46
+ `package.json` asks for Swiper 12; the lockfile still pins 11.
47
+
48
+ ```jsonc
49
+ // package.json — hand-edited
50
+ {
51
+ "dependencies": {
52
+ "swiper": "^12.0.0"
53
+ }
54
+ }
55
+ ```
56
+
57
+ ```jsonc
58
+ // package-lock.json — never regenerated
59
+ {
60
+ "packages": {
61
+ "node_modules/swiper": { "version": "11.2.10" }
62
+ }
63
+ }
64
+ ```
65
+
66
+ `npm ci`: `Invalid: lock file's swiper@11.2.10 does not satisfy swiper@^12.0.0`.
67
+
68
+ ### After
69
+
70
+ ```bash
71
+ npm install # resolves ^12.0.0 and rewrites package-lock.json
72
+ git add package.json package-lock.json
73
+ ```
74
+
75
+ ```jsonc
76
+ // package-lock.json — back in sync
77
+ {
78
+ "packages": {
79
+ "node_modules/swiper": { "version": "12.0.4" }
80
+ }
81
+ }
82
+ ```
83
+
84
+ ## See also
85
+
86
+ - [`lockfile-missing`](lockfile-missing.md) — there is no lockfile at all.
87
+ - [`single-react`](single-react.md) — the other property of the installed tree the platform
88
+ checks before it builds your page.
@@ -0,0 +1,140 @@
1
+ ---
2
+ purpose: The template-kit/missing-slot-marker authoring rule — what it enforces, why, and how to fix it.
3
+ status: living
4
+ related: [INDEX.md, ../primitives.md, ../schema-system.md]
5
+ updated: 2026-07-14
6
+ ---
7
+ # template-kit/missing-slot-marker
8
+
9
+ > Every slot declared `editable_by_user: true` must be **marked** in the section's JSX.
10
+ > An unmarked slot still fills and still renders — it just cannot be clicked.
11
+
12
+ ## Rule
13
+
14
+ For each slot in `schema.ts` with `editable_by_user: true`, the section's JSX must carry a
15
+ marker whose key is that slot id, written as a **string literal**. Any of these counts:
16
+
17
+ | Marker | Example |
18
+ |---|---|
19
+ | `<Slot id>` | `<Slot id="headline" textLeaf>` |
20
+ | `slotId` on a primitive that emits its own marker | `<Image slotId="hero_image" …>` |
21
+ | a raw `data-slot-id` attribute | `<div data-slot-id="headline">` |
22
+
23
+ The scan covers **every `.tsx` in the section folder**, not only `Renderer.tsx` — a marker
24
+ inside a section-local component satisfies it. Test files are excluded.
25
+
26
+ Carve-outs:
27
+
28
+ - **A slot that is not `editable_by_user` needs no marker.** It cannot be selected in the
29
+ editor, so there is nothing to mark. (`editable_by_user` defaults to `false`.)
30
+ - **A computed key does not satisfy the rule.** `<Slot id={fooKey}>` or an id produced
31
+ inside a `.map()` is not a literal, so the check cannot resolve it — and
32
+ [`slot-marker-literal`](slot-marker-literal.md) reports it separately. A `.map()`-generated
33
+ key therefore fails both at once.
34
+
35
+ The practical corollary of the two above: when you hoist markup into a section-local
36
+ helper, **do not pass the slot key through it as a prop**. `<Field slotKey="headline">`
37
+ wrapping an inner `<Slot id={slotKey}>` erases the literal from the source entirely, and
38
+ the slot reads as unmarked. Keep the literal `<Slot id="…">` where the slot is rendered
39
+ and move only the data layer and leaf presentation out.
40
+
41
+ ## Reason
42
+
43
+ The markers are the only thing connecting the pixels a user clicks to the schema key
44
+ behind them. The editor's click resolver walks up from the clicked node looking for a
45
+ `data-slot-id`; a slot that emits none is invisible to it.
46
+
47
+ Nothing throws. The slot is filled by AI, it renders its content on the published page,
48
+ and it looks completely correct — right up until a user clicks it in the editor and
49
+ selects nothing. That is the whole failure: **fillable but not editable**, discovered by a
50
+ customer rather than by you.
51
+
52
+ ## Fix
53
+
54
+ Wrap the slot's outer DOM in `<Slot id="…">` — add `textLeaf` for a single-leaf text slot
55
+ so the editor can write to it on each keystroke. An image slot needs no wrapper: give
56
+ `<Image>` its `slotId` and it emits the marker itself.
57
+
58
+ A raw `data-slot-id` attribute is not a lesser fallback — for the marker itself, it's
59
+ identical to what `<Slot>` renders: `<Slot as="p" id="…">` emits exactly `<p
60
+ data-slot-id="…">`, nothing more. The one thing `<Slot textLeaf>` adds is
61
+ `data-slot-text-leaf` on the child, wired automatically, so the editor can patch text on
62
+ every keystroke instead of waiting for the next server render. So default to `<Slot>` for
63
+ the convenience — it's one prop instead of hand-wiring a second attribute — but writing
64
+ `data-slot-id` directly on markup you're already hand-assembling is a legitimate, idiomatic
65
+ fix, not a technicality; reach for it freely when you don't need `textLeaf`.
66
+
67
+ If the slot genuinely is not meant to be edited, say so in the schema rather than leaving
68
+ the check unsatisfied: `editable_by_user: false`.
69
+
70
+ ### Before
71
+
72
+ ```ts
73
+ // sections/hero/schema.ts
74
+ import type { SectionProps, SectionSchemaShape } from "@homepages/template-kit";
75
+
76
+ export const schema = {
77
+ meta: { displayName: "Hero" },
78
+ slots: {
79
+ headline: { type: "text", size: "medium", required: true, editable_by_user: true },
80
+ eyebrow: { type: "text", size: "short", required: false, editable_by_user: true },
81
+ },
82
+ } as const satisfies SectionSchemaShape;
83
+
84
+ export type Props = SectionProps<typeof schema>;
85
+ ```
86
+
87
+ ```tsx
88
+ // sections/hero/Renderer.tsx — template-kit/missing-slot-marker: eyebrow
89
+ import { Section, Slot } from "@homepages/template-kit";
90
+
91
+ import type { Props } from "./schema.js";
92
+
93
+ export function Renderer({ slots }: Props) {
94
+ return (
95
+ <Section>
96
+ <p className="text-ink-mute">{slots.eyebrow}</p>
97
+ <Slot id="headline" as="h1" textLeaf>
98
+ <span>{slots.headline}</span>
99
+ </Slot>
100
+ </Section>
101
+ );
102
+ }
103
+ ```
104
+
105
+ ### After
106
+
107
+ ```tsx
108
+ // sections/hero/Renderer.tsx
109
+ import { Section, Slot } from "@homepages/template-kit";
110
+
111
+ import type { Props } from "./schema.js";
112
+
113
+ export function Renderer({ slots }: Props) {
114
+ return (
115
+ <Section>
116
+ <Slot id="eyebrow" textLeaf>
117
+ <p className="text-ink-mute">{slots.eyebrow}</p>
118
+ </Slot>
119
+ <Slot id="headline" as="h1" textLeaf>
120
+ <span>{slots.headline}</span>
121
+ </Slot>
122
+ </Section>
123
+ );
124
+ }
125
+ ```
126
+
127
+ If `eyebrow` was never meant to be editable, the other legitimate fix is in the schema —
128
+ and then no marker is needed:
129
+
130
+ ```ts
131
+ eyebrow: { type: "text", size: "short", required: false, editable_by_user: false },
132
+ ```
133
+
134
+ ## See also
135
+
136
+ - [Contract primitives](../primitives.md#the-marker-contract) — every marker attribute and
137
+ which primitive emits it.
138
+ - [`slot-marker-literal`](slot-marker-literal.md) — why the key must be a literal for this
139
+ check to see it at all.
140
+ - [`slot-group-marker`](slot-group-marker.md) — the same idea for a group of slots.
@@ -0,0 +1,128 @@
1
+ ---
2
+ purpose: The template-kit/no-bare-css-import 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/no-bare-css-import
8
+
9
+ > No `@import` in hand-written CSS. **Not even a package's** — the browser drops it
10
+ > silently, and the styles simply never ship.
11
+
12
+ ## Rule
13
+
14
+ A hand-written stylesheet may not contain a top-level `@import`. That means the
15
+ stylesheets the CSS build collects out of your section and template folders — a section's
16
+ `styles.css`, any other `.css` in a section folder, and a template's own hand-written
17
+ stylesheet.
18
+
19
+ **There is no carve-out for a package specifier.** `@import "swiper/css";` is exactly as
20
+ illegal here as `@import "./tokens.css";`. Both are reported, at the 1-based line of the
21
+ first offender.
22
+
23
+ The scan is comment-aware, so an `@import` written *about* rather than *as* code is prose
24
+ and passes:
25
+
26
+ ```css
27
+ /* css-reason: … */
28
+ /* Never @import here — see template-kit/no-bare-css-import. */ ← fine, it's a comment
29
+ ```
30
+
31
+ **Your Tailwind entry is not one of these files.** The file that carries
32
+ `@import "tailwindcss";` and `@import "@homepages/template-kit/styles.css";` is a Tailwind
33
+ entry: it is compiled by Tailwind, its `@import`s are resolved at build time, and it is
34
+ never wrapped in a cascade layer. It is not hand-written section or template CSS, and this
35
+ rule does not reach it.
36
+
37
+ ## Reason
38
+
39
+ Every hand-CSS file the build collects is **concatenated into a cascade layer**:
40
+
41
+ ```css
42
+ @layer sections {
43
+ /* your styles.css, pasted in here */
44
+ }
45
+ ```
46
+
47
+ That is how a section's CSS lands in the layer order (`theme, base, components, sections,
48
+ template, utilities`) instead of fighting Tailwind's utilities for specificity.
49
+
50
+ **`@import` is invalid inside a `@layer` block.** And CSS's error handling for an invalid
51
+ at-rule is not an error — it is a shrug. The browser drops the statement and parses on.
52
+ Nothing throws. Nothing warns. Your build succeeds, your preview looks *plausible* because
53
+ the package's own styling is missing in a way you have learned to read as "unstyled yet",
54
+ and the published page ships without the rules you imported. This is the quietest failure
55
+ in the whole kit: the only signal is that the styles are not there.
56
+
57
+ ## Fix
58
+
59
+ Delete the `@import`.
60
+
61
+ **A package's stylesheet is reached from the code that needs it**, not from CSS. Write
62
+ `import "swiper/css";` in the component, island, or enhancer that renders the thing — the
63
+ CSS build bundles your `Renderer.tsx` and every marked enhancer, transitively, precisely to
64
+ harvest those imports, and it compiles what they pull in into **this section's cascade
65
+ layer**. Package CSS goes in first and your own hand-CSS second, so at equal specificity
66
+ your rules win.
67
+
68
+ Two things authors get wrong immediately after:
69
+
70
+ - **Bare specifiers only.** A CSS import that resolves outside `node_modules` is an error,
71
+ not a fallback. Your own stylesheets are discovered by filename — importing one from code
72
+ would ship it twice. See
73
+ [`no-css-import-from-render-path`](no-css-import-from-render-path.md).
74
+ - **Importing is not installing.** `import "swiper/css"` does not add `swiper` to your
75
+ `package.json`. Add the dependency, or the install that builds your submission has nothing
76
+ to resolve.
77
+
78
+ ### Before
79
+
80
+ ```css
81
+ /* sections/gallery/styles.css */
82
+ /* css-reason: styles the slide DOM the Swiper enhancer builds at runtime. */
83
+ @import "swiper/css"; /* template-kit/no-bare-css-import — silently dropped */
84
+
85
+ .tr-gallery .swiper-pagination-bullet-active {
86
+ background: var(--tr-color-primary);
87
+ }
88
+ ```
89
+
90
+ The pagination override ships. Swiper's own layout CSS does not — and no error says so.
91
+
92
+ ### After
93
+
94
+ The `@import` is gone from the stylesheet:
95
+
96
+ ```css
97
+ /* sections/gallery/styles.css */
98
+ /* css-reason: overrides the Swiper pagination DOM the enhancer builds at runtime —
99
+ those nodes carry the library's own classes, which Tailwind's scanner never sees. */
100
+ .tr-gallery .swiper-pagination-bullet-active {
101
+ background: var(--tr-color-primary);
102
+ }
103
+ ```
104
+
105
+ …and the package's CSS is imported from the module that needs it, which is what routes it
106
+ into this section's layer:
107
+
108
+ ```tsx
109
+ // sections/gallery/Carousel.tsx
110
+ "use client";
111
+
112
+ import Swiper from "swiper";
113
+ import "swiper/css"; // ← the sanctioned channel: the CSS build harvests this import
114
+
115
+ export default function Carousel({ slides }: { slides: { id: string; url: string }[] }) {
116
+ // …
117
+ }
118
+ ```
119
+
120
+ ## See also
121
+
122
+ - [`no-css-import-from-render-path`](no-css-import-from-render-path.md) — the other half of
123
+ this mental model. Hand-written CSS: no `@import`s at all, package or otherwise. Render
124
+ path: no *local* CSS imports, but a package's bare specifier is exactly right. An author
125
+ who reads only one of the two pages will be confused by the other.
126
+ - [`css-reason`](css-reason.md) — the comment every hand-written stylesheet opens with.
127
+ - [Theme and CSS](../theme-and-css.md) — the cascade-layer order, and the one file that
128
+ legitimately does `@import "tailwindcss"`.
@@ -0,0 +1,111 @@
1
+ ---
2
+ purpose: The template-kit/no-client-directive-in-contract authoring rule — what it enforces, why, and how to fix it.
3
+ status: living
4
+ related: [INDEX.md, server-vs-client.md, ../schema-system.md]
5
+ updated: 2026-07-14
6
+ ---
7
+ # template-kit/no-client-directive-in-contract
8
+
9
+ > A section's four contract files may never carry `"use client"`. This is the one rule
10
+ > scoped by **filename**, not by the directive.
11
+
12
+ ## Rule
13
+
14
+ The rule runs only on a section's four contract files — `Renderer.tsx`, `schema.ts`,
15
+ `fill-spec.ts`, `fixtures.ts` — matched by glob. On one of those files, a `"use client"`
16
+ string in the **directive prologue** is reported.
17
+
18
+ The prologue is the run of leading string-literal statements (see
19
+ [server vs client](server-vs-client.md)). Three consequences worth knowing:
20
+
21
+ - **`"use strict"; "use client";` reports.** A second directive is still in the prologue.
22
+ - **`const x = 1;` above `"use client"` does not report** — real code ended the prologue,
23
+ so that string is not a directive. This is a trap, not a loophole: the file is also
24
+ **not an island**, and every server-rendering rule still applies to it. It is a dead
25
+ string.
26
+ - **`foo("use client")` is not a directive** — it is a call argument, and is ignored.
27
+
28
+ Any other file in the section folder may carry the directive freely. That is exactly
29
+ where interactivity belongs.
30
+
31
+ ## Reason
32
+
33
+ The four contract files are the platform's interface to a section. They are read by the
34
+ publisher, the editor, and the fill pipeline — **none of which is a browser**.
35
+ `schema.ts` is loaded to learn what slots exist; `fill-spec.ts` to learn how AI fills
36
+ them; `fixtures.ts` to render a preview; `Renderer.tsx` to produce the section's HTML.
37
+
38
+ Marking one `"use client"` would make the whole section an island — and take the
39
+ section's server-rendered HTML with it. The section would ship as an empty shell that
40
+ only appears once JavaScript has run, which is precisely what a published property page
41
+ must never do.
42
+
43
+ ## Fix
44
+
45
+ Put the interactive part in its **own** `"use client"` component in the section folder,
46
+ and render it from the Renderer. The contract file stays server-only; the island next to
47
+ it does the browser work.
48
+
49
+ ### Before
50
+
51
+ ```tsx
52
+ // sections/gallery/Renderer.tsx
53
+ "use client";
54
+
55
+ import { useState } from "react";
56
+
57
+ import { Section } from "@homepages/template-kit";
58
+
59
+ import type { Props } from "./schema.js";
60
+
61
+ export function Renderer({ slots }: Props) {
62
+ const [open, setOpen] = useState(false);
63
+ return (
64
+ <Section>
65
+ <button onClick={() => setOpen(true)}>Open gallery</button>
66
+ {open && <p>{slots.headline}</p>}
67
+ </Section>
68
+ );
69
+ }
70
+ ```
71
+
72
+ ### After
73
+
74
+ ```tsx
75
+ // sections/gallery/Lightbox.tsx
76
+ "use client";
77
+
78
+ import { useState } from "react";
79
+
80
+ export default function Lightbox({ headline }: { headline: string }) {
81
+ const [open, setOpen] = useState(false);
82
+ return (
83
+ <div>
84
+ <button onClick={() => setOpen(true)}>Open gallery</button>
85
+ {open && <p>{headline}</p>}
86
+ </div>
87
+ );
88
+ }
89
+ ```
90
+
91
+ ```tsx
92
+ // sections/gallery/Renderer.tsx — no directive; server-rendered again
93
+ import { Section } from "@homepages/template-kit";
94
+
95
+ import Lightbox from "./Lightbox.js";
96
+ import type { Props } from "./schema.js";
97
+
98
+ export function Renderer({ slots }: Props) {
99
+ return (
100
+ <Section>
101
+ <Lightbox headline={slots.headline} />
102
+ </Section>
103
+ );
104
+ }
105
+ ```
106
+
107
+ ## See also
108
+
109
+ - [Server vs client](server-vs-client.md) — the directive, the prologue, and what an
110
+ island may do.
111
+ - [Schema system](../schema-system.md) — what each of the four contract files is for.
@@ -0,0 +1,143 @@
1
+ ---
2
+ purpose: The template-kit/no-client-runtime-in-server authoring rule — what it enforces, why, and how to fix it.
3
+ status: living
4
+ related: [INDEX.md, server-vs-client.md, ../islands.md, serializable-island-props.md]
5
+ updated: 2026-07-14
6
+ ---
7
+ # template-kit/no-client-runtime-in-server
8
+
9
+ > No effects, state, event handlers, network, or browser globals in server-rendered code.
10
+ > Interactivity is not banned — it is **relocated** into an island. Islands are exempt.
11
+
12
+ ## Rule
13
+
14
+ Whether a file is server-rendered or an island is decided per file, by the `"use client"`
15
+ directive — see [server vs client](server-vs-client.md). On an island this rule installs
16
+ no visitors at all.
17
+
18
+ In a server-rendered file it reports three things:
19
+
20
+ - **React hooks that only mean something in the browser** — `useEffect`,
21
+ `useLayoutEffect`, `useState`, `useReducer`, `useRef`, `useSyncExternalStore`,
22
+ `useImperativeHandle` — called bare (`useState(…)`) or namespace-qualified
23
+ (`React.useEffect(…)`).
24
+ - **Browser globals read as a value** — `fetch`, `XMLHttpRequest`, `window`, `document`,
25
+ `localStorage`, `sessionStorage`, `navigator`. "Read as a value" is exact: the SSR-guard
26
+ idiom `typeof window !== "undefined"` **is** reported, while `cfg.window` (a property)
27
+ and `{ window: 1 }` (an object key) are not.
28
+ - **Event-handler props** — any JSX prop matching `on[A-Z]…` whose value is an expression:
29
+ `onClick={…}`, `onChange={…}`, `onPointerMove={…}`.
30
+
31
+ Carve-outs:
32
+
33
+ - **Your own definitions are yours.** A name that resolves to a real local declaration —
34
+ `function fetch() {}`, your own `useRef` helper — is not the global or React's hook, and
35
+ is left alone.
36
+ - **`onSurface={1}` and `onIcon={<Icon />}` are not handlers.** A prop whose value is
37
+ plainly a literal or a JSX element is not reported.
38
+ - **A handler on an island is not reported here.** If the element's component resolves to
39
+ a real `"use client"` file on disk, this rule stays quiet:
40
+ [`serializable-island-props`](serializable-island-props.md) already speaks for that
41
+ prop, and "move it into a client component" would be nonsense advice for something that
42
+ already is one.
43
+
44
+ ## Reason
45
+
46
+ A server-rendered file is run once, to a string of HTML, in a process that is not a
47
+ browser. There is no `window` to read and no DOM to mutate; an effect never fires, and
48
+ `useState` has no second render to give you. An event handler is worse than useless: it
49
+ is a function, and a function cannot be serialized into HTML, so **it is silently dropped
50
+ from the published page**. The button ships. The click does nothing. Nothing errors.
51
+
52
+ So a Renderer must be a **pure function of its props**. Interactivity is not something the
53
+ kit forbids — it is something the kit gives a specific home: a `"use client"` island,
54
+ which is hydrated in the browser and is exempt from every one of these bans.
55
+
56
+ ## Fix
57
+
58
+ Move the interactive markup into a `"use client"` component in the section folder, and
59
+ render it from the Renderer with plain data props. The Renderer keeps the static markup
60
+ and passes the island what it needs to do its job; the island owns the state and the
61
+ handlers.
62
+
63
+ ### Before
64
+
65
+ ```tsx
66
+ // sections/floorplan/Renderer.tsx
67
+ import { useState } from "react";
68
+
69
+ import { Section } from "@homepages/template-kit";
70
+
71
+ import type { Props } from "./schema.js";
72
+
73
+ export function Renderer({ slots }: Props) {
74
+ const [beds, setBeds] = useState<number | null>(null);
75
+ const shown = beds === null ? slots.units : slots.units.filter((u) => u.beds === beds);
76
+ return (
77
+ <Section>
78
+ <button onClick={() => setBeds(2)}>2 beds</button>
79
+ <ul>
80
+ {shown.map((u) => (
81
+ <li key={u.id}>{u.label}</li>
82
+ ))}
83
+ </ul>
84
+ </Section>
85
+ );
86
+ }
87
+ ```
88
+
89
+ ### After
90
+
91
+ The state and the handler move into an island. Its props are plain data — see
92
+ [`serializable-island-props`](serializable-island-props.md).
93
+
94
+ ```tsx
95
+ // sections/floorplan/UnitFilter.tsx
96
+ "use client";
97
+
98
+ import { useState } from "react";
99
+
100
+ type Unit = { id: string; label: string; beds: number };
101
+
102
+ export default function UnitFilter({ units }: { units: Unit[] }) {
103
+ const [beds, setBeds] = useState<number | null>(null);
104
+ const shown = beds === null ? units : units.filter((u) => u.beds === beds);
105
+ return (
106
+ <div>
107
+ <button onClick={() => setBeds(2)}>2 beds</button>
108
+ <ul>
109
+ {shown.map((u) => (
110
+ <li key={u.id}>{u.label}</li>
111
+ ))}
112
+ </ul>
113
+ </div>
114
+ );
115
+ }
116
+ ```
117
+
118
+ ```tsx
119
+ // sections/floorplan/Renderer.tsx
120
+ import { Section } from "@homepages/template-kit";
121
+
122
+ import UnitFilter from "./UnitFilter.js";
123
+ import type { Props } from "./schema.js";
124
+
125
+ export function Renderer({ slots }: Props) {
126
+ return (
127
+ <Section>
128
+ <UnitFilter units={slots.units} />
129
+ </Section>
130
+ );
131
+ }
132
+ ```
133
+
134
+ The import carries the **emitted** extension (`./UnitFilter.js`) while the file on disk is
135
+ `UnitFilter.tsx` — that is TypeScript's ESM convention, and it is what the kit's rules
136
+ resolve against.
137
+
138
+ ## See also
139
+
140
+ - [Server vs client](server-vs-client.md) — how a file is scoped, and what an island may do.
141
+ - [Islands](../islands.md) — writing one, its props contract, and its editor options.
142
+ - [`serializable-island-props`](serializable-island-props.md) — the rule that instead
143
+ reports a function-valued prop when the target *is* an island.