@homepages/template-kit 0.1.0 → 0.3.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 (111) hide show
  1. package/CHANGELOG.md +220 -0
  2. package/README.md +70 -16
  3. package/dist/base.css +36 -48
  4. package/dist/cli/check/config.js +37 -0
  5. package/dist/cli/check/css.js +117 -0
  6. package/dist/cli/check/diagnostics.js +32 -0
  7. package/dist/cli/check/index.js +87 -0
  8. package/dist/cli/check/loader.js +142 -0
  9. package/dist/cli/check/paths.js +15 -0
  10. package/dist/cli/check/relativize.js +18 -0
  11. package/dist/cli/check/render-invariants.js +24 -0
  12. package/dist/cli/check/resolve-tool.js +38 -0
  13. package/dist/cli/check/stages/deps.js +337 -0
  14. package/dist/cli/check/stages/lint.js +46 -0
  15. package/dist/cli/check/stages/render.js +101 -0
  16. package/dist/cli/check/stages/size.js +158 -0
  17. package/dist/cli/check/stages/tree.js +207 -0
  18. package/dist/cli/check/stages/typecheck.js +46 -0
  19. package/dist/cli/check/stages/validate/catalog-exhaustiveness.js +18 -0
  20. package/dist/cli/check/stages/validate/facts.js +18 -0
  21. package/dist/cli/check/stages/validate/formatter-contract.js +14 -0
  22. package/dist/cli/check/stages/validate/group-contract.js +21 -0
  23. package/dist/cli/check/stages/validate/index.js +66 -0
  24. package/dist/cli/check/stages/validate/list-scalar-contract.js +22 -0
  25. package/dist/cli/check/stages/validate/nav-contract.js +93 -0
  26. package/dist/cli/check/stages/validate/nested-slot-contract.js +12 -0
  27. package/dist/cli/check/stages/validate/object-list-contract.js +31 -0
  28. package/dist/cli/check/stages/validate/orchestrator.js +241 -0
  29. package/dist/cli/check/stages/validate/produced-by-contract.js +18 -0
  30. package/dist/cli/check/stages/validate/row-contract.js +31 -0
  31. package/dist/cli/check/stages/validate/select-contract.js +13 -0
  32. package/dist/cli/check/stages/validate/source-contract.js +21 -0
  33. package/dist/cli/check/stages/validate/variant-contract.js +15 -0
  34. package/dist/cli/check/workspace.js +86 -0
  35. package/dist/cli/theme/generate.js +43 -0
  36. package/dist/cli/theme/index.js +33 -0
  37. package/dist/cli/theme/load-theme.js +69 -0
  38. package/dist/cli.js +22 -4
  39. package/dist/contracts/fill-treatments.js +47 -0
  40. package/dist/design-system/theme.d.ts +30 -299
  41. package/dist/design-system/theme.js +112 -83
  42. package/dist/eslint/rules/image-bare-needs-reason.js +6 -1
  43. package/dist/eslint/rules/no-client-directive-in-contract.js +6 -1
  44. package/dist/eslint/rules/no-client-runtime-in-server.js +5 -1
  45. package/dist/eslint/rules/no-css-import-from-render-path.js +8 -2
  46. package/dist/eslint/rules/no-hex.js +84 -7
  47. package/dist/eslint/rules/no-inline-style.js +5 -1
  48. package/dist/eslint/rules/no-nondeterminism.js +5 -1
  49. package/dist/eslint/rules/no-raw-element.js +6 -1
  50. package/dist/eslint/rules/props-from-schema.js +6 -1
  51. package/dist/eslint/rules/serializable-island-props.js +5 -1
  52. package/dist/eslint/rules/slot-marker-literal.js +6 -1
  53. package/dist/eslint.js +1 -1
  54. package/dist/index.d.ts +2 -2
  55. package/dist/index.js +2 -2
  56. package/dist/package.js +1 -1
  57. package/dist/primitives/Image.js +1 -1
  58. package/dist/rules/registry.js +16 -0
  59. package/dist/schema/fill-spec.d.ts +80 -590
  60. package/dist/schema/fixture-schema.d.ts +5 -81
  61. package/dist/schema/manifest.d.ts +39 -475
  62. package/dist/schema/resolve-section-ref.js +10 -0
  63. package/dist/schema/rows.js +10 -0
  64. package/dist/schema/section-nav.js +9 -0
  65. package/dist/schema/section-schema.d.ts +51 -437
  66. package/dist/schema/slot-types.d.ts +12 -16
  67. package/dist/styles.css +31 -88
  68. package/docs/INDEX.md +5 -3
  69. package/docs/check.md +121 -0
  70. package/docs/eslint.md +25 -50
  71. package/docs/llms.txt +26 -11
  72. package/docs/primitives.md +5 -0
  73. package/docs/rules/INDEX.md +65 -0
  74. package/docs/rules/audit-severity.md +91 -0
  75. package/docs/rules/bundle-binary-asset.md +102 -0
  76. package/docs/rules/bundle-incomplete.md +117 -0
  77. package/docs/rules/css-reason.md +94 -0
  78. package/docs/rules/determinism-drift.md +112 -0
  79. package/docs/rules/fixtures-invalid.md +96 -0
  80. package/docs/rules/image-bare-needs-reason.md +117 -0
  81. package/docs/rules/license-denied.md +114 -0
  82. package/docs/rules/lockfile-missing.md +68 -0
  83. package/docs/rules/lockfile-stale.md +88 -0
  84. package/docs/rules/manifest-invalid.md +99 -0
  85. package/docs/rules/missing-slot-marker.md +140 -0
  86. package/docs/rules/no-bare-css-import.md +128 -0
  87. package/docs/rules/no-client-directive-in-contract.md +111 -0
  88. package/docs/rules/no-client-runtime-in-server.md +143 -0
  89. package/docs/rules/no-css-import-from-render-path.md +118 -0
  90. package/docs/rules/no-hex.md +163 -0
  91. package/docs/rules/no-inline-style.md +121 -0
  92. package/docs/rules/no-nondeterminism.md +100 -0
  93. package/docs/rules/no-raw-element.md +100 -0
  94. package/docs/rules/no-templates.md +87 -0
  95. package/docs/rules/parse-error.md +76 -0
  96. package/docs/rules/props-from-schema.md +119 -0
  97. package/docs/rules/render-invariant.md +165 -0
  98. package/docs/rules/schema-invalid.md +96 -0
  99. package/docs/rules/serializable-island-props.md +146 -0
  100. package/docs/rules/server-vs-client.md +105 -0
  101. package/docs/rules/sidebar-order.md +109 -0
  102. package/docs/rules/single-react.md +97 -0
  103. package/docs/rules/size-renderer-bundle.md +109 -0
  104. package/docs/rules/size-section-css.md +95 -0
  105. package/docs/rules/slot-group-marker.md +127 -0
  106. package/docs/rules/slot-marker-literal.md +148 -0
  107. package/docs/rules/typecheck.md +103 -0
  108. package/docs/rules/unlayered-fence.md +113 -0
  109. package/docs/theme-and-css.md +193 -82
  110. package/package.json +4 -9
  111. package/tsconfig.json +1 -1
@@ -0,0 +1,96 @@
1
+ ---
2
+ purpose: The template-kit/schema-invalid authoring rule — what it enforces, why, and how to fix it.
3
+ status: living
4
+ related: [INDEX.md, ../schema-system.md, fixtures-invalid.md, manifest-invalid.md]
5
+ updated: 2026-07-14
6
+ ---
7
+ # template-kit/schema-invalid
8
+
9
+ > `schema.ts` and `fill-spec.ts` are a matched pair. This is every check that makes sure
10
+ > they actually agree.
11
+
12
+ ## Rule
13
+
14
+ A section's `schema.ts` and `fill-spec.ts` must, together:
15
+
16
+ - **parse** — each is `as const satisfies` its shape (`SectionSchemaShape`,
17
+ `FillSpecShape`); a value that doesn't satisfy it fails here with the parse error;
18
+ - **cross-reference correctly** — every `fill-spec.ts` decision produces a slot that
19
+ actually exists in `schema.ts`, every `reads` entry names a real, author-declarable
20
+ facts key, every option's `affects_decisions`/`affects_slots` names a real decision or
21
+ slot;
22
+ - **satisfy the closed per-type contracts** — an `object_list` slot's `item_order`
23
+ covers every field, a `select` slot's cases match its transform, a group's members are
24
+ slots that exist, and the rest of the per-slot-type rules the schema system documents.
25
+
26
+ One violation is one diagnostic; a section with several wrong things reports all of them
27
+ in the same run, not one per fix.
28
+
29
+ This is the id you also see when a section **cannot be built or parsed at all** — a
30
+ syntax error in `schema.ts`, a missing export, an import that doesn't resolve. The
31
+ message in that case is the underlying build error, relocated to the file it points at.
32
+
33
+ ## Reason
34
+
35
+ `fill-spec.ts` is not free-standing — every decision it declares exists to fill a slot
36
+ `schema.ts` declared, and every fact it reads must be one the platform actually exposes.
37
+ A decision that produces a slot no schema declares, or reads a fact that doesn't exist,
38
+ is not a style problem: the fill pipeline would either drop the output on the floor or
39
+ crash trying to read a fact key that isn't there. Catching the mismatch here, at author
40
+ time, is the difference between a red check and a listing that silently fails to fill.
41
+
42
+ ## Fix
43
+
44
+ Read the message: it names the section, the decision or slot in question, and what's
45
+ wrong with it. Fix whichever side is actually stale — the schema, or the fill-spec.
46
+
47
+ ### Before
48
+
49
+ ```ts
50
+ // sections/facts/schema.ts
51
+ export const schema = {
52
+ meta: { displayName: "Facts" },
53
+ slots: {
54
+ year_built: { type: "text", required: false, produced_by: "year_built_text" },
55
+ },
56
+ options: {},
57
+ } as const satisfies SectionSchemaShape;
58
+ ```
59
+
60
+ ```ts
61
+ // sections/facts/fill-spec.ts
62
+ export const fillSpec = {
63
+ reads: [],
64
+ decisions: [
65
+ { id: "year_built_text", type: "text-block", produces: ["year_built_summary"], length_cap: 20 },
66
+ // ^ typo — schema.ts declares "year_built"
67
+ ],
68
+ } as const satisfies FillSpecShape;
69
+ ```
70
+
71
+ ```text
72
+ Section "facts": fill-spec decision "year_built_text" produces unknown slot "year_built_summary".
73
+ ```
74
+
75
+ ### After
76
+
77
+ ```ts
78
+ // sections/facts/fill-spec.ts
79
+ export const fillSpec = {
80
+ reads: [],
81
+ decisions: [
82
+ { id: "year_built_text", type: "text-block", produces: ["year_built"], length_cap: 20 },
83
+ ],
84
+ } as const satisfies FillSpecShape;
85
+ ```
86
+
87
+ ## See also
88
+
89
+ - [The schema system](../schema-system.md) — the full authoring contract for `schema.ts`
90
+ and `fill-spec.ts`.
91
+ - [`fixtures-invalid`](fixtures-invalid.md) — the sibling gate on the third contract
92
+ file, `fixtures.ts`.
93
+ - [`manifest-invalid`](manifest-invalid.md) — the same kind of cross-reference check, one
94
+ level up, on a template composing several sections.
95
+ - [`bundle-incomplete`](bundle-incomplete.md) — reported instead of this id when a
96
+ contract file is missing outright, rather than present and wrong.
@@ -0,0 +1,146 @@
1
+ ---
2
+ purpose: The template-kit/serializable-island-props authoring rule — what it enforces, why, and how to fix it.
3
+ status: living
4
+ related: [INDEX.md, server-vs-client.md, ../islands.md, no-client-runtime-in-server.md]
5
+ updated: 2026-07-14
6
+ ---
7
+ # template-kit/serializable-island-props
8
+
9
+ > Props handed to an island must be JSON-serializable. This rule is about what a file
10
+ > *hands to* an island, so it has **no island exemption** — it runs everywhere.
11
+
12
+ ## Rule
13
+
14
+ The rule looks at every JSX element whose component was imported from a file that is a
15
+ real `"use client"` module on disk. On those elements — and only those — it reports any
16
+ prop value it can statically see to be un-JSON-able:
17
+
18
+ - **a literal function** — an arrow or a `function` expression (`onSelect={() => …}`);
19
+ - **`new Anything()`** — `new Date()`, `new Map()`, `new Set()`, `new URL(…)`,
20
+ `new RegExp(…)`, `new YourClass()`. Everything except `new Object()` and `new Array()`,
21
+ which are plain and cross fine;
22
+ - **a bigint literal** — `10n`;
23
+ - **`NaN` and `Infinity`** — JSON has neither.
24
+
25
+ It recurses into object and array literals, and names the exact path it found:
26
+ `props.nested.deep.cb`, `props.units[0].created`.
27
+
28
+ Carve-outs:
29
+
30
+ - **A getter or setter in an object literal is skipped.** An accessor reads a value back
31
+ out; it never receives one across the boundary.
32
+ - **A value handed over as a plain identifier is invisible.** `onPick={pick}` reports
33
+ nothing — the rule cannot see what `pick` is. That case is deliberately left to the
34
+ runtime check, which throws on it. The lint catches what it can see, early; the runtime
35
+ catches the rest.
36
+ - **This rule stops at the island boundary — the preset does not.**
37
+ `<UnitTable onSelect={() => {}} />` is not reported *by this rule*, because
38
+ `UnitTable` is not an island and no prop crosses a serialization boundary. But inside
39
+ a server-rendered Renderer, that same line is still reported — by
40
+ [`no-client-runtime-in-server`](no-client-runtime-in-server.md), which bans any
41
+ `on*` JSX prop carrying a function on a non-island target, because the handler is
42
+ dropped from the published HTML regardless of what it's attached to. Moving a
43
+ handler off an island and onto an ordinary component doesn't clear the violation —
44
+ it trades this rule's error for that one.
45
+
46
+ ## Reason
47
+
48
+ **Props are JSON on the wire.** An island is server-rendered inline with the page, and
49
+ then its props are emitted next to it as a JSON script tag, which the browser parses back
50
+ before hydrating. So a prop is not passed to the island — a *JSON round-trip of* the prop
51
+ is.
52
+
53
+ A function does not survive that trip. Neither does a `Date` (it becomes a string), a
54
+ `Map` (it becomes `{}`), or `NaN` (it becomes `null`). The runtime refuses rather than
55
+ shipping a corrupted prop: it throws `IslandPropsError`, naming the path. This rule says
56
+ the same thing in your editor, in the same words, before the render ever runs.
57
+
58
+ ## Fix
59
+
60
+ **Pass data, not behavior.** An island owns its own handlers, so it does not need yours.
61
+ A `Date`/`Map`/`Set`/`RegExp` crosses as its plain-JSON equivalent — an ISO string, an
62
+ array of entries — which you reconstruct inside the island. For `NaN`/`Infinity`, pass a
63
+ finite number, or encode the sentinel as a string prop the island parses.
64
+
65
+ That reconstruction step assumes the value is backed by real data. A `Date` built from a
66
+ literal — `new Date(2020, 0, 1)`, with no slot behind it — has no reason to be a `Date`
67
+ object on the server at all: write the ISO string (or just the year, or whatever the
68
+ island actually needs) as a plain literal prop, and skip the round-trip entirely.
69
+
70
+ ### Before
71
+
72
+ ```tsx
73
+ // sections/floorplan/Renderer.tsx
74
+ import { Section } from "@homepages/template-kit";
75
+
76
+ import UnitFilter from "./UnitFilter.js";
77
+ import type { Props } from "./schema.js";
78
+
79
+ export function Renderer({ slots }: Props) {
80
+ return (
81
+ <Section>
82
+ <UnitFilter
83
+ units={slots.units}
84
+ onSelect={(id: string) => console.log(id)}
85
+ created={new Date()}
86
+ />
87
+ </Section>
88
+ );
89
+ }
90
+ ```
91
+
92
+ ### After
93
+
94
+ The handler moves inside the island, where it belongs. The date crosses as an ISO string
95
+ and is rebuilt on the other side.
96
+
97
+ ```tsx
98
+ // sections/floorplan/Renderer.tsx
99
+ import { Section } from "@homepages/template-kit";
100
+
101
+ import UnitFilter from "./UnitFilter.js";
102
+ import type { Props } from "./schema.js";
103
+
104
+ export function Renderer({ slots }: Props) {
105
+ return (
106
+ <Section>
107
+ <UnitFilter units={slots.units} createdIso={slots.listed_at} />
108
+ </Section>
109
+ );
110
+ }
111
+ ```
112
+
113
+ ```tsx
114
+ // sections/floorplan/UnitFilter.tsx
115
+ "use client";
116
+
117
+ import { useState } from "react";
118
+
119
+ type Unit = { id: string; label: string };
120
+
121
+ export default function UnitFilter({ units, createdIso }: { units: Unit[]; createdIso: string }) {
122
+ const [selected, setSelected] = useState<string | null>(null);
123
+ const created = new Date(createdIso); // reconstructed inside the island
124
+ return (
125
+ <div>
126
+ <p>Listed {created.getFullYear()}</p>
127
+ <ul>
128
+ {units.map((u) => (
129
+ <li key={u.id}>
130
+ <button onClick={() => setSelected(u.id)}>{u.label}</button>
131
+ </li>
132
+ ))}
133
+ </ul>
134
+ {selected !== null && <p>Selected {selected}</p>}
135
+ </div>
136
+ );
137
+ }
138
+ ```
139
+
140
+ ## See also
141
+
142
+ - [Islands: props must be JSON-serializable](../islands.md#props-must-be-json-serializable) —
143
+ the full allowed/rejected table and the runtime error.
144
+ - [Server vs client](server-vs-client.md) — how a file is scoped.
145
+ - [`no-client-runtime-in-server`](no-client-runtime-in-server.md) — the rule that reports
146
+ an event-handler prop on a non-island target, since this one doesn't.
@@ -0,0 +1,105 @@
1
+ ---
2
+ purpose: The one concept the server-rendering rules assume — how a file is scoped server or client, and which rules stop at that boundary.
3
+ status: living
4
+ related: [INDEX.md, ../islands.md, ../eslint.md]
5
+ updated: 2026-07-14
6
+ ---
7
+ # Server vs client
8
+
9
+ Several rules ban something "in server-rendered code" and say an island is exempt.
10
+ This page is what "server-rendered" and "island" mean, once, so the rule pages do not
11
+ each re-derive it.
12
+
13
+ ## Scoping is per file, by the directive
14
+
15
+ A file is a **client module** if and only if its own directive prologue says
16
+ `"use client"`. Nothing else decides it — not what it imports, not what imports *it*,
17
+ not the directory it sits in, not a build transform.
18
+
19
+ The consequence surprises people: **the same code fails in one file and passes in the
20
+ file next to it.**
21
+
22
+ ```tsx
23
+ // sections/hero/Renderer.tsx — no directive → server-rendered
24
+ export function Renderer() {
25
+ return <p>{new Date().getFullYear()}</p>; // template-kit/no-nondeterminism
26
+ }
27
+ ```
28
+
29
+ ```tsx
30
+ // sections/hero/Clock.tsx — directive → island
31
+ "use client";
32
+
33
+ export default function Clock() {
34
+ return <p>{new Date().getFullYear()}</p>; // fine
35
+ }
36
+ ```
37
+
38
+ Importing an island from a server file does **not** make the server file an island. It
39
+ also does not make the island a server file. Each is scoped on its own.
40
+
41
+ ## The prologue
42
+
43
+ The directive prologue is the run of **leading string-literal statements**. Comments and
44
+ blank lines do not end it; **any other statement does**.
45
+
46
+ ```tsx
47
+ "use strict";
48
+ "use client"; // still in the prologue → this file IS an island
49
+ ```
50
+
51
+ ```tsx
52
+ const x = 1;
53
+ "use client"; // real code ended the prologue → this is NOT a directive, and NOT an island
54
+ ```
55
+
56
+ The second file is the trap. It looks like an island, it is not one, and every
57
+ server-rendering rule still applies to it. If you meant it to be an island, the
58
+ directive must be the first statement in the file.
59
+
60
+ ## What each side must be
61
+
62
+ **A server-rendered file** (no directive) runs in the publisher and in the editor, and
63
+ must be a **pure function of its props**: same props in, byte-identical HTML out. No
64
+ clock, no randomness, no effects, no state, no event handlers, no network, no browser
65
+ globals. It has none of those things available, and the two renders must agree.
66
+
67
+ **An island** (with the directive) is server-rendered inline with the rest of the page
68
+ and then **hydrated** in the browser as its own React root. Effects, state, event
69
+ handlers, and inline `style` are all legal there — that is what an island is for. Its
70
+ props cross the server→browser boundary as JSON, which is the one new constraint it
71
+ takes on (see [`serializable-island-props`](serializable-island-props.md)).
72
+
73
+ Interactivity is never banned. It is **relocated**: out of the server-rendered file, into
74
+ a `"use client"` component in the same section folder, rendered from the Renderer like
75
+ any other component.
76
+
77
+ ## Exactly three rules exempt islands
78
+
79
+ | Rule | Exempt on a `"use client"` file? |
80
+ |---|---|
81
+ | [`no-nondeterminism`](no-nondeterminism.md) | **yes** — installs no visitors on an island |
82
+ | [`no-client-runtime-in-server`](no-client-runtime-in-server.md) | **yes** |
83
+ | [`no-inline-style`](no-inline-style.md) | **yes** |
84
+ | every other rule | **no** — it runs on island files too |
85
+
86
+ `no-hex` deliberately does **not** exempt islands. The three exemptions above are all
87
+ server-render concerns, and an island runs in the browser. A palette is not a render-path
88
+ concern: **the hydration boundary is not a palette boundary.** A section carries no
89
+ palette of its own in either render path, so `"use client"` plus
90
+ `style={{ color: "#ff0000" }}` passes `no-inline-style` and still fails
91
+ [`no-hex`](no-hex.md).
92
+
93
+ ## The one rule scoped by filename
94
+
95
+ [`no-client-directive-in-contract`](no-client-directive-in-contract.md) is governed by
96
+ this boundary but is **not** scoped by the directive — it is scoped by **file glob**. It
97
+ runs on a section's four contract files (`Renderer.tsx`, `schema.ts`, `fill-spec.ts`,
98
+ `fixtures.ts`) and says those files may never carry the directive at all. They are the
99
+ platform's interface to the section and are read outside a browser; making one an island
100
+ would take the section's server-rendered HTML with it.
101
+
102
+ ## See also
103
+
104
+ - [Islands](../islands.md) — writing one, its props contract, and its editor options.
105
+ - [Rule index](INDEX.md) — every rule id, in both venues.
@@ -0,0 +1,109 @@
1
+ ---
2
+ purpose: The template-kit/sidebar-order authoring rule — what it enforces, why, and how to fix it.
3
+ status: living
4
+ related: [INDEX.md, ../schema-system.md]
5
+ updated: 2026-07-14
6
+ ---
7
+ # template-kit/sidebar-order
8
+
9
+ > The editor sidebar's cards are ordered by the **schema**. The page is ordered by your
10
+ > **JSX**. They must agree — and when they don't, the schema is what moves.
11
+
12
+ ## Rule
13
+
14
+ The editor renders one sidebar card per group (and one per ungrouped slot), in the order
15
+ the schema declares them. This check server-renders the section's baseline fixture, reads
16
+ the **first-appearance order of the slot markers** in the resulting HTML, and asserts that
17
+ it matches the schema's declaration order.
18
+
19
+ Three carve-outs, each of which looks like a bug if you don't know about it:
20
+
21
+ - **A slot rendered more than once counts at its first marker.** An address shown in a
22
+ header and again in a footer is ordered by the header.
23
+ - **Order *within* a group is not checked, and neither is contiguity.** A group's members
24
+ may appear in any order on the page, and other slots may sit between them. Responsive
25
+ layouts legitimately do both, and the sidebar collapses the group into one card anyway.
26
+ - **A slot declared but never rendered is excluded.** It has no position on the page, so it
27
+ cannot contradict one.
28
+
29
+ ## Reason
30
+
31
+ Two files describe the same section in two different orders, and only one of them is
32
+ visible to the person editing. The failure mode is drift: you reorder the markup — move the
33
+ eyebrow above the headline, promote the price — the schema keeps its original order, and
34
+ the sidebar quietly stops matching the page.
35
+
36
+ Nothing breaks. The section renders, the slots fill, and every card still works. The user
37
+ just finds that the second card edits the first thing they see, and stops trusting the
38
+ panel. This check makes that drift impossible to ship rather than merely unlikely.
39
+
40
+ ## Fix
41
+
42
+ **Reorder the schema, not the page.** The page's visual order is the truth — it is what the
43
+ user is looking at — so bring `schema.ts` to it: reorder the `slots` keys, and, if you have
44
+ groups, the `meta.groups` entries, so declaration order matches the page's top-to-bottom
45
+ order.
46
+
47
+ ### Before
48
+
49
+ The schema declares `headline` first, the page renders `eyebrow` first:
50
+
51
+ ```ts
52
+ // sections/hero/schema.ts — template-kit/sidebar-order
53
+ import type { SectionProps, SectionSchemaShape } from "@homepages/template-kit";
54
+
55
+ export const schema = {
56
+ meta: { displayName: "Hero" },
57
+ slots: {
58
+ headline: { type: "text", size: "medium", required: true, editable_by_user: true },
59
+ eyebrow: { type: "text", size: "short", required: false, editable_by_user: true },
60
+ price: { type: "text", size: "short", required: false, editable_by_user: true },
61
+ },
62
+ } as const satisfies SectionSchemaShape;
63
+
64
+ export type Props = SectionProps<typeof schema>;
65
+ ```
66
+
67
+ ```tsx
68
+ // sections/hero/Renderer.tsx — the page, top to bottom: eyebrow, headline, price
69
+ import { Section, Slot } from "@homepages/template-kit";
70
+
71
+ import type { Props } from "./schema.js";
72
+
73
+ export function Renderer({ slots }: Props) {
74
+ return (
75
+ <Section>
76
+ <Slot id="eyebrow" textLeaf><p>{slots.eyebrow}</p></Slot>
77
+ <Slot id="headline" as="h1" textLeaf><span>{slots.headline}</span></Slot>
78
+ <Slot id="price" textLeaf><p>{slots.price}</p></Slot>
79
+ </Section>
80
+ );
81
+ }
82
+ ```
83
+
84
+ ### After
85
+
86
+ The Renderer is untouched. The schema moves:
87
+
88
+ ```ts
89
+ // sections/hero/schema.ts
90
+ import type { SectionProps, SectionSchemaShape } from "@homepages/template-kit";
91
+
92
+ export const schema = {
93
+ meta: { displayName: "Hero" },
94
+ slots: {
95
+ eyebrow: { type: "text", size: "short", required: false, editable_by_user: true },
96
+ headline: { type: "text", size: "medium", required: true, editable_by_user: true },
97
+ price: { type: "text", size: "short", required: false, editable_by_user: true },
98
+ },
99
+ } as const satisfies SectionSchemaShape;
100
+
101
+ export type Props = SectionProps<typeof schema>;
102
+ ```
103
+
104
+ ## See also
105
+
106
+ - [The schema system](../schema-system.md#grouping-slots-into-one-editor-card) — `meta.groups`,
107
+ whose entry order is the other half of the sidebar's card order.
108
+ - [`missing-slot-marker`](missing-slot-marker.md) — the markers this check reads to
109
+ establish the page's order.
@@ -0,0 +1,97 @@
1
+ ---
2
+ purpose: The template-kit/single-react authoring rule — what it enforces, why, and how to fix it.
3
+ status: living
4
+ related: [INDEX.md, ../islands.md]
5
+ updated: 2026-07-14
6
+ ---
7
+ # template-kit/single-react
8
+
9
+ > Exactly one copy of `react` and one of `react-dom` in the installed tree. A second copy
10
+ > breaks hydration **only in production** — never in your preview.
11
+
12
+ ## Rule
13
+
14
+ The installed tree may contain **one** `react` and **one** `react-dom`. A nested duplicate
15
+ anywhere under `node_modules` is a violation, whoever pulled it in.
16
+
17
+ React is a **peer** dependency of the kit, not a bundled one: the kit ships against the
18
+ React your workspace installs. So do your islands, and so does every React package you add.
19
+ They all have to be the same React.
20
+
21
+ ## Reason
22
+
23
+ React's hooks are not self-contained. `useState` does not hold your state — it dispatches
24
+ through a **module-level** dispatcher that the currently-rendering React sets. Two copies of
25
+ React on a page means two dispatchers, and an island rendered by one while hydrated by the
26
+ other reads a dispatcher that is null or foreign. Hydration throws (`Invalid hook call` is
27
+ the usual spelling), the island never comes alive, and whatever it was — the filter, the
28
+ carousel, the lightbox — is inert on the page.
29
+
30
+ **What makes this the worst kind of bug is where it shows up.** Islands are hydrated on the
31
+ **published page**. In the editor, an island renders as static markup unless it opts into
32
+ `live: true`, so it is never hydrated and the two Reacts never meet. Your preview is
33
+ perfect. Your submission builds. The section is dead on the customer's site, and nowhere
34
+ else.
35
+
36
+ ## Fix
37
+
38
+ Get the tree back to one copy:
39
+
40
+ 1. **`npm dedupe`** — if the versions are compatible, npm hoists them to a single copy and
41
+ you are done.
42
+ 2. **Align your own range.** The kit's peer range is `react@^19` / `react-dom@^19`; if your
43
+ `package.json` pins something older, npm has no choice but to install two.
44
+ 3. **Deal with the package that pinned its own.** A dependency that depends on a React
45
+ *version range your React does not satisfy* will always get its own nested copy. Widen
46
+ its range if it has a newer release, or replace the package. There is no way to make two
47
+ Reacts coexist.
48
+
49
+ Confirm with `npm ls react react-dom` — one entry each, no `deduped`-less nested copies.
50
+
51
+ ### Before
52
+
53
+ ```jsonc
54
+ // package.json
55
+ {
56
+ "dependencies": {
57
+ "react": "^19.0.0",
58
+ "react-dom": "^19.0.0",
59
+ "legacy-carousel": "^2.1.0" // depends on react@^18
60
+ }
61
+ }
62
+ ```
63
+
64
+ ```text
65
+ $ npm ls react
66
+ template-workspace
67
+ ├─┬ legacy-carousel@2.1.0
68
+ │ └── react@18.3.1 ← a second React, nested
69
+ └── react@19.1.0
70
+ ```
71
+
72
+ ### After
73
+
74
+ `legacy-carousel` is replaced with a package whose peer range accepts React 19:
75
+
76
+ ```jsonc
77
+ // package.json
78
+ {
79
+ "dependencies": {
80
+ "react": "^19.0.0",
81
+ "react-dom": "^19.0.0",
82
+ "swiper": "^12.0.0"
83
+ }
84
+ }
85
+ ```
86
+
87
+ ```text
88
+ $ npm ls react
89
+ template-workspace
90
+ └── react@19.1.0
91
+ ```
92
+
93
+ ## See also
94
+
95
+ - [Islands](../islands.md) — what gets hydrated on the published page, and what does not get
96
+ hydrated in the editor.
97
+ - [`lockfile-stale`](lockfile-stale.md) — the other check that reads your installed tree.
@@ -0,0 +1,109 @@
1
+ ---
2
+ purpose: The template-kit/size-renderer-bundle authoring rule — what it enforces, why, and how to fix it.
3
+ status: living
4
+ related: [INDEX.md, size-section-css.md]
5
+ updated: 2026-07-14
6
+ ---
7
+ # template-kit/size-renderer-bundle
8
+
9
+ > A section's renderer bundle must be **≤ 50 KB gzip**. The budget is not
10
+ > author-overridable.
11
+
12
+ ## Rule
13
+
14
+ Each section's renderer is bundled with esbuild and minified, with **React treated as
15
+ external** (it is a peer, shared by the whole page, and is not counted against any one
16
+ section). The gzipped result must be **50 KB or less**.
17
+
18
+ The budget is **visible in `template-kit check`'s configuration and is not
19
+ author-overridable**. A template workspace cannot raise it, and there is no per-section
20
+ exemption. If you go looking for the flag, this is the answer: there isn't one.
21
+
22
+ ## Reason
23
+
24
+ The output is a real page for a real buyer, standing in a driveway on a phone. Bytes are the
25
+ one cost the section cannot delegate.
26
+
27
+ The number is set to be generous — roughly **twice** the largest section shipping today — so
28
+ it does not fight ordinary authoring. You will not hit it by writing a rich section. You hit
29
+ it when something **runaway** gets pulled in:
30
+
31
+ - **a barrel import of an icon set**, which drags every icon into the bundle so you can use
32
+ three;
33
+ - **a package that brings a font, a locale table, or a polyfill along with it**, none of
34
+ which you asked for.
35
+
36
+ That is the shape of essentially every violation: not a section that grew, but a single
37
+ import that was more expensive than it looked.
38
+
39
+ ## Fix
40
+
41
+ Find the import that is not paying for itself.
42
+
43
+ - **Import named symbols, never a namespace.** `import { ArrowRight } from "…"` is
44
+ tree-shakeable; `import * as Icons from "…"` is not — a namespace object has to contain
45
+ everything, so nothing can be dropped.
46
+ - **Prefer the package's narrow entry** if it offers one.
47
+ - **Hand-write the residue.** If you use one function out of a large utility package, copy
48
+ the function.
49
+
50
+ **Do not try to hoist code out of the section to dodge the budget.** Each template owns its
51
+ own presentation code; there is no shared library above a section to hoist it into, and the
52
+ bytes would still land on the same page.
53
+
54
+ ### Before
55
+
56
+ ```tsx
57
+ // sections/amenities/Renderer.tsx
58
+ import { Section, Slot, SlotItem } from "@homepages/template-kit";
59
+ import * as Icons from "lucide-react"; // ← the whole set: every icon, in the bundle
60
+
61
+ import type { Props } from "./schema.js";
62
+
63
+ export function Renderer({ slots }: Props) {
64
+ return (
65
+ <Section>
66
+ <Slot id="amenities">
67
+ {slots.amenities.map((item, i) => (
68
+ <SlotItem key={typeof item.id === "string" ? item.id : i} index={i} as="li">
69
+ <Icons.Check aria-hidden />
70
+ <h3>{typeof item.label === "string" ? item.label : null}</h3>
71
+ </SlotItem>
72
+ ))}
73
+ </Slot>
74
+ </Section>
75
+ );
76
+ }
77
+ ```
78
+
79
+ ### After
80
+
81
+ ```tsx
82
+ // sections/amenities/Renderer.tsx
83
+ import { Section, Slot, SlotItem } from "@homepages/template-kit";
84
+ import { Check } from "lucide-react"; // ← one icon, and the bundler can drop the rest
85
+
86
+ import type { Props } from "./schema.js";
87
+
88
+ export function Renderer({ slots }: Props) {
89
+ return (
90
+ <Section>
91
+ <Slot id="amenities">
92
+ {slots.amenities.map((item, i) => (
93
+ <SlotItem key={typeof item.id === "string" ? item.id : i} index={i} as="li">
94
+ <Check aria-hidden />
95
+ <h3>{typeof item.label === "string" ? item.label : null}</h3>
96
+ </SlotItem>
97
+ ))}
98
+ </Slot>
99
+ </Section>
100
+ );
101
+ }
102
+ ```
103
+
104
+ ## See also
105
+
106
+ - [`size-section-css`](size-section-css.md) — the same budget on the other half of what the
107
+ section ships.
108
+ - [`license-denied`](license-denied.md) — the other reason to look hard at a dependency
109
+ before you take it.