@homepages/template-kit 0.1.0 → 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 (54) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +12 -2
  3. package/dist/base.css +27 -48
  4. package/dist/design-system/theme.d.ts +2 -1
  5. package/dist/design-system/theme.js +10 -3
  6. package/dist/eslint/rules/image-bare-needs-reason.js +6 -1
  7. package/dist/eslint/rules/no-client-directive-in-contract.js +6 -1
  8. package/dist/eslint/rules/no-client-runtime-in-server.js +5 -1
  9. package/dist/eslint/rules/no-css-import-from-render-path.js +8 -2
  10. package/dist/eslint/rules/no-hex.js +6 -1
  11. package/dist/eslint/rules/no-inline-style.js +5 -1
  12. package/dist/eslint/rules/no-nondeterminism.js +5 -1
  13. package/dist/eslint/rules/no-raw-element.js +6 -1
  14. package/dist/eslint/rules/props-from-schema.js +6 -1
  15. package/dist/eslint/rules/serializable-island-props.js +5 -1
  16. package/dist/eslint/rules/slot-marker-literal.js +6 -1
  17. package/dist/package.js +1 -1
  18. package/dist/rules/registry.js +16 -0
  19. package/dist/styles.css +1 -1
  20. package/docs/INDEX.md +2 -1
  21. package/docs/eslint.md +12 -47
  22. package/docs/llms.txt +7 -4
  23. package/docs/rules/INDEX.md +58 -0
  24. package/docs/rules/audit-severity.md +91 -0
  25. package/docs/rules/bundle-incomplete.md +117 -0
  26. package/docs/rules/css-reason.md +94 -0
  27. package/docs/rules/determinism-drift.md +112 -0
  28. package/docs/rules/image-bare-needs-reason.md +117 -0
  29. package/docs/rules/license-denied.md +106 -0
  30. package/docs/rules/lockfile-missing.md +68 -0
  31. package/docs/rules/lockfile-stale.md +88 -0
  32. package/docs/rules/missing-slot-marker.md +140 -0
  33. package/docs/rules/no-bare-css-import.md +128 -0
  34. package/docs/rules/no-client-directive-in-contract.md +111 -0
  35. package/docs/rules/no-client-runtime-in-server.md +143 -0
  36. package/docs/rules/no-css-import-from-render-path.md +118 -0
  37. package/docs/rules/no-hex.md +97 -0
  38. package/docs/rules/no-inline-style.md +121 -0
  39. package/docs/rules/no-nondeterminism.md +100 -0
  40. package/docs/rules/no-raw-element.md +100 -0
  41. package/docs/rules/props-from-schema.md +119 -0
  42. package/docs/rules/render-invariant.md +165 -0
  43. package/docs/rules/serializable-island-props.md +146 -0
  44. package/docs/rules/server-vs-client.md +105 -0
  45. package/docs/rules/sidebar-order.md +109 -0
  46. package/docs/rules/single-react.md +97 -0
  47. package/docs/rules/size-renderer-bundle.md +109 -0
  48. package/docs/rules/size-section-css.md +95 -0
  49. package/docs/rules/slot-group-marker.md +127 -0
  50. package/docs/rules/slot-marker-literal.md +148 -0
  51. package/docs/rules/typecheck.md +103 -0
  52. package/docs/rules/unlayered-fence.md +113 -0
  53. package/docs/theme-and-css.md +57 -11
  54. package/package.json +1 -1
@@ -0,0 +1,119 @@
1
+ ---
2
+ purpose: The template-kit/props-from-schema 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/props-from-schema
8
+
9
+ > A Renderer's props type is **derived** from `schema.ts`, never hand-written in the
10
+ > Renderer.
11
+
12
+ ## Rule
13
+
14
+ The rule runs only on `Renderer.tsx`. It reports an **exported** type alias or interface
15
+ whose name **ends in `Props`** — `Props`, `HeroProps`, `GalleryProps`.
16
+
17
+ Two consequences of that being the whole test:
18
+
19
+ - **It does not read the right-hand side.** Even `export type Props = SectionProps<typeof
20
+ schema>;` reports *if you write it in the Renderer*. The derivation belongs in
21
+ `schema.ts`; the Renderer **imports** it.
22
+ - **A non-exported local type is invisible** to the rule. An internal
23
+ `type Row = { id: string };` used to type a helper's parameter is your business, and is
24
+ not reported.
25
+
26
+ `import type { Props } from "./schema.js";` is the shape the rule is steering you to, and
27
+ it reports nothing.
28
+
29
+ ## Reason
30
+
31
+ The schema is the section's single source of truth, and the prop type is **inferred from
32
+ it** — `SectionProps<typeof schema>` reads the schema literal and resolves each slot to
33
+ its runtime value shape: a required `text` becomes `string`, an optional one
34
+ `string | null`, an `image` becomes `ImageValue`, and so on.
35
+
36
+ A hand-written `Props` shadows that inference, and then **drifts silently**. Add a slot
37
+ to `schema.ts` and forget to render it: with the derived type, reading `slots.subhead` in
38
+ the Renderer is a compile error until the slot exists, and the platform's tooling and
39
+ your renderer agree by construction. With a hand-written `Props`, the Renderer keeps
40
+ compiling happily against the old shape — while the editor, the fill pipeline, and the
41
+ schema all now believe in a slot your markup has never heard of. Nothing fails until a
42
+ user is looking at a page with a missing block in it.
43
+
44
+ It also forces the editor to reason about a second definition of the same thing.
45
+
46
+ ## Fix
47
+
48
+ Declare the derived type once in `schema.ts` and import it in the Renderer:
49
+
50
+ ```ts
51
+ export type Props = SectionProps<typeof schema>;
52
+ ```
53
+
54
+ ```ts
55
+ import type { Props } from "./schema.js";
56
+ ```
57
+
58
+ ### Before
59
+
60
+ ```tsx
61
+ // sections/hero/Renderer.tsx
62
+ import { Section, Slot } from "@homepages/template-kit";
63
+
64
+ export type Props = {
65
+ slots: { headline: string };
66
+ };
67
+
68
+ export function Renderer({ slots }: Props) {
69
+ return (
70
+ <Section>
71
+ <Slot id="headline" as="h1" textLeaf>
72
+ <span>{slots.headline}</span>
73
+ </Slot>
74
+ </Section>
75
+ );
76
+ }
77
+ ```
78
+
79
+ ### After
80
+
81
+ ```ts
82
+ // sections/hero/schema.ts
83
+ import type { SectionProps, SectionSchemaShape } from "@homepages/template-kit";
84
+
85
+ export const schema = {
86
+ meta: { displayName: "Hero" },
87
+ slots: {
88
+ headline: { type: "text", size: "medium", required: true, produced_by: "headline_text" },
89
+ },
90
+ } as const satisfies SectionSchemaShape;
91
+
92
+ export type Props = SectionProps<typeof schema>;
93
+ ```
94
+
95
+ ```tsx
96
+ // sections/hero/Renderer.tsx
97
+ import { Section, Slot } from "@homepages/template-kit";
98
+
99
+ import type { Props } from "./schema.js";
100
+
101
+ export function Renderer({ slots }: Props) {
102
+ return (
103
+ <Section>
104
+ <Slot id="headline" as="h1" textLeaf>
105
+ <span>{slots.headline}</span>
106
+ </Slot>
107
+ </Section>
108
+ );
109
+ }
110
+ ```
111
+
112
+ The `as const satisfies SectionSchemaShape` is load-bearing: without it the literal types
113
+ are widened away and `SectionProps` can no longer tell a required slot from an optional
114
+ one.
115
+
116
+ ## See also
117
+
118
+ - [Schema system](../schema-system.md) — the schema literal, `SectionProps` inference,
119
+ and the four contract files.
@@ -0,0 +1,165 @@
1
+ ---
2
+ purpose: The template-kit/render-invariant 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/render-invariant
8
+
9
+ > Three things must never reach a published page: `[object Object]`, a bare
10
+ > `null`/`undefined`/`NaN`, and an `<img>` with no source. Every one of them is garbage a
11
+ > real buyer would see.
12
+
13
+ ## Rule
14
+
15
+ Every section is server-rendered against every fixture in its invariant corpus, and the
16
+ output is checked for three defects:
17
+
18
+ 1. **The text `[object Object]`**, anywhere in the rendered HTML.
19
+ 2. **A text leaf that is exactly `null`, `undefined`, or `NaN`.** The match is on the whole
20
+ leaf, so prose that merely contains the word ("null and void") is safe.
21
+ 3. **An `<img>` whose `src` is absent, empty, `"undefined"`, or `"null"`.**
22
+
23
+ The corpus is your `base` fixture, each of your `states` fixtures, and one **synthesized**
24
+ fixture per optional slot with that slot set to its empty value (`null` for a scalar or an
25
+ image, `[]` for a list) — derived from the schema, not declared by you.
26
+
27
+ The check is deliberately biased toward **false negatives**: it would rather miss a bug than
28
+ invent one. Everything it reports is a real defect, so there is nothing here to argue with
29
+ or configure around.
30
+
31
+ ## Reason
32
+
33
+ **Your fixtures are richer than the content a real listing gets filled with.** That is the
34
+ whole story, and almost every violation of this rule is a version of it.
35
+
36
+ You wrote `fixtures.ts` from a property you had in front of you: twelve units, a year
37
+ built, a hero photo, an agent headshot. The fill engine, on a thin listing, bakes in what
38
+ actually exists — which may be no units array at all, no year, no photo. Your preview looks
39
+ perfect, your states look perfect, and the section still renders `Built undefined` on a
40
+ real customer's page, or throws on `slots.units.map` because `units` is `null`.
41
+
42
+ This is precisely why the corpus adds the synthesized nullability fixtures. They are
43
+ adversarial inputs, not designed states: they render your section against exactly the thin
44
+ content your own fixtures never showed you.
45
+
46
+ So the rule to internalize is not "avoid these three strings." It is: **an absent slot is
47
+ routine, not exceptional** — and your markup has to say what happens then.
48
+
49
+ ### Where the three defects actually come from
50
+
51
+ Worth being precise, because the obvious guesses are wrong. Rendering a nullish slot as a
52
+ **child** is safe: React renders `null` and `undefined` as nothing, so `<p>{slots.year_built}</p>`
53
+ on a thin listing is an empty `<p>`, not the text `undefined`. And a list slot is **never
54
+ `null`** — `list`, `object_list`, `poi_list` and `image_collection` always arrive as an array
55
+ (empty when absent), so `.map` cannot throw and `?? []` buys you nothing.
56
+
57
+ The defects come from three narrower places:
58
+
59
+ - **String coercion.** A template literal, `String(x)`, `"" + x`, `.join()`, or **any JSX
60
+ string attribute** (`alt`, `title`, `aria-label`, `src`) turns `null` into `"null"`, an
61
+ absent field into `"undefined"`, and an object into `"[object Object]"`. Coercion is what
62
+ React's own child handling was protecting you from.
63
+ - **Arithmetic on an absent number.** `null * 1.05` is `NaN`, and React renders `NaN` as
64
+ text.
65
+ - **`<Image bare>` with no source.** Framed `<Image>` emits no `<img>` at all when `src` is
66
+ empty — it renders its fallback rect. `bare` forfeits that and ships `<img src="">`.
67
+
68
+ ## Fix
69
+
70
+ - **Never coerce a slot into a string.** Branch on it and render the surrounding text as
71
+ markup, or guard it before it reaches a template literal or an attribute.
72
+ - **Guard arithmetic** behind a `typeof x === "number"` check.
73
+ - **Format an `object_list` row's fields** — they are typed `unknown`, so pick, check, and
74
+ format each one; never interpolate the row.
75
+ - **Let framed `<Image>` handle a missing image**, and reach for `bare` only where the source
76
+ is guaranteed.
77
+
78
+ ### Before
79
+
80
+ ```tsx
81
+ // sections/units/Renderer.tsx
82
+ import { Image, Section, Slot, SlotItem } from "@homepages/template-kit";
83
+
84
+ import type { Props } from "./schema.js";
85
+
86
+ export function Renderer({ slots }: Props) {
87
+ return (
88
+ <Section>
89
+ {/* coerced: an absent year_built lands in the heading as the text "null" */}
90
+ <h2>{`${slots.headline} — built ${slots.year_built}`}</h2>
91
+
92
+ <Slot id="units">
93
+ {slots.units.map((unit, i) => (
94
+ <SlotItem key={String(unit.id)} index={i} as="li">
95
+ {/* coerced: a unit with no price renders "2 Bed · undefined" */}
96
+ <h3>{`${unit.label} · ${unit.price}`}</h3>
97
+ {/* arithmetic on an absent sqft: NaN, rendered as text */}
98
+ <p>{Number(unit.sqft) * 1.05} sq ft heated</p>
99
+ </SlotItem>
100
+ ))}
101
+ </Slot>
102
+
103
+ {/* bare: the card grid already sizes the thumbnail. */}
104
+ <Image bare src={slots.floorplan?.url ?? ""} alt="Floor plan" />
105
+ </Section>
106
+ );
107
+ }
108
+ ```
109
+
110
+ On the golden property this renders perfectly. On a listing with no year, an unpriced unit,
111
+ and no floor plan it ships `built null`, `2 Bed · undefined`, `NaN sq ft heated`, and
112
+ `<img src="">`.
113
+
114
+ ### After
115
+
116
+ ```tsx
117
+ // sections/units/Renderer.tsx
118
+ import { Image, Section, Slot, SlotItem } from "@homepages/template-kit";
119
+
120
+ import type { Props } from "./schema.js";
121
+
122
+ export function Renderer({ slots }: Props) {
123
+ return (
124
+ <Section>
125
+ <h2>
126
+ {slots.headline}
127
+ {slots.year_built ? ` — built ${slots.year_built}` : null}
128
+ </h2>
129
+
130
+ <Slot id="units">
131
+ {slots.units.map((unit, i) => (
132
+ <SlotItem key={typeof unit.id === "string" ? unit.id : i} index={i} as="li">
133
+ <h3>
134
+ {typeof unit.label === "string" ? unit.label : null}
135
+ {typeof unit.price === "number" ? ` · ${unit.price}` : null}
136
+ </h3>
137
+ {typeof unit.sqft === "number" ? (
138
+ <p>{Math.round(unit.sqft * 1.05)} sq ft heated</p>
139
+ ) : null}
140
+ </SlotItem>
141
+ ))}
142
+ </Slot>
143
+
144
+ <Image
145
+ slotId="floorplan"
146
+ src={slots.floorplan?.url ?? null}
147
+ alt={slots.floorplan?.alt ?? ""}
148
+ aspectRatio="4 / 3"
149
+ />
150
+ </Section>
151
+ );
152
+ }
153
+ ```
154
+
155
+ Every coercion now sits behind a check, and the image is framed: a null `src` renders the
156
+ fallback rect, holding the layout box and keeping the slot selectable instead of shipping a
157
+ source-less `<img>`.
158
+
159
+ ## See also
160
+
161
+ - [Contract primitives: `Image`](../primitives.md#image--the-defensive-image-primitive) — what
162
+ the frame does with a missing source, and why `bare` forfeits it.
163
+ - [`typecheck`](typecheck.md) — the same class of bug, caught earlier, for the cases the
164
+ types can see.
165
+ - [`determinism-drift`](determinism-drift.md) — the other check that runs over this corpus.
@@ -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.