@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,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.
@@ -0,0 +1,118 @@
1
+ ---
2
+ purpose: The template-kit/no-css-import-from-render-path 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-css-import-from-render-path
8
+
9
+ > No **local** `.css` import in a section's render path. A **package's** `.css` import
10
+ > belongs there — it is how its stylesheet reaches the section's cascade layer.
11
+
12
+ ## Rule
13
+
14
+ An `import` whose specifier ends in **`.css`**, in any of the section's `.ts`/`.tsx`
15
+ files, is reported **only when the specifier is local** — relative
16
+ (`import "./gallery.css";`) or absolute (`import "/gallery.css";`) — including the
17
+ side-effect-only form with no bindings.
18
+
19
+ A **bare** specifier (`import "swiper/css";`, `import "@scope/pkg/dist/style.css";`) is
20
+ not reported. That is the sanctioned channel for a third-party package's stylesheet.
21
+
22
+ What it does **not** match:
23
+
24
+ - `.scss` and `.less` — a different pipeline, out of this rule's scope;
25
+ - `require("./x.css")`;
26
+ - a dynamic `import("./x.css")`.
27
+
28
+ There is no island exemption: a `"use client"` file is part of the render path too — it is
29
+ server-rendered inline before it is hydrated.
30
+
31
+ ## Reason
32
+
33
+ The section's CSS build works by **bundling the render path itself** — `Renderer.tsx` and
34
+ any enhancer that carries `export const enhancer` — and harvesting the CSS its imports
35
+ pull in. A bare specifier resolves into `node_modules`, which is exactly what that build
36
+ looks for: `import "swiper/css";` in `Renderer.tsx` is not a mistake, it is the mechanism.
37
+
38
+ A **local** stylesheet is a different problem. Every `.css` file that already lives in the
39
+ section's folder is collected on its own, by filename — the build doesn't need an import
40
+ to find it. Importing it again from the render path doesn't just ship duplicate rules
41
+ harmlessly: it can also smuggle a stray `@layer` order statement into a nested layer and
42
+ corrupt the cascade for the whole page.
43
+
44
+ ## Fix
45
+
46
+ Delete the import. A local stylesheet already ships — the build picks up every `.css` file
47
+ in the section folder on its own; importing it again only makes it double-emit.
48
+
49
+ A package's stylesheet is different: import it **by its bare specifier**, from the module
50
+ that needs it. The build bundles `Renderer.tsx` and every marked enhancer's import graph
51
+ **transitively**, so a bare specifier is equally legal in the Renderer itself, in a marked
52
+ enhancer, or in any component either one imports — not only at the top of the tree. That
53
+ import is how the build finds the stylesheet and routes it into the section's cascade
54
+ layer.
55
+
56
+ ### Before
57
+
58
+ ```tsx
59
+ // sections/gallery/Renderer.tsx
60
+ import { Section } from "@homepages/template-kit";
61
+
62
+ import "./styles.css"; // template-kit/no-css-import-from-render-path — already collected by filename
63
+
64
+ import type { Props } from "./schema.js";
65
+
66
+ export function Renderer({ slots }: Props) {
67
+ return (
68
+ <Section>
69
+ <div className="tr-gallery-track">{slots.headline}</div>
70
+ </Section>
71
+ );
72
+ }
73
+ ```
74
+
75
+ ### After
76
+
77
+ The local import is simply deleted — `styles.css` was already shipping:
78
+
79
+ ```tsx
80
+ // sections/gallery/Renderer.tsx
81
+ import { Section } from "@homepages/template-kit";
82
+
83
+ import type { Props } from "./schema.js";
84
+
85
+ export function Renderer({ slots }: Props) {
86
+ return (
87
+ <Section>
88
+ <div className="tr-gallery-track">{slots.headline}</div>
89
+ </Section>
90
+ );
91
+ }
92
+ ```
93
+
94
+ Compare a **package's** stylesheet, which is the legal counterpart — imported by its bare
95
+ specifier, from the same file, and left in place:
96
+
97
+ ```tsx
98
+ // sections/gallery/Renderer.tsx
99
+ import { Section } from "@homepages/template-kit";
100
+ import "swiper/css"; // legal — a bare specifier is how package CSS reaches this section's layer
101
+
102
+ import type { Props } from "./schema.js";
103
+
104
+ export function Renderer({ slots }: Props) {
105
+ return (
106
+ <Section>
107
+ <div className="tr-gallery-track">{slots.headline}</div>
108
+ </Section>
109
+ );
110
+ }
111
+ ```
112
+
113
+ ## See also
114
+
115
+ - [`no-bare-css-import`](no-bare-css-import.md) — the other half of this mental model.
116
+ Render path: no *local* CSS imports. Hand-written CSS: no `@import`s at all, package or
117
+ otherwise. An author who reads only one of the two pages will be confused by the other.
118
+ - [Theme and CSS](../theme-and-css.md) — the two CSS entries and how they load.
@@ -0,0 +1,97 @@
1
+ ---
2
+ purpose: The template-kit/no-hex authoring rule — what it enforces, why, and how to fix it.
3
+ status: living
4
+ related: [INDEX.md, server-vs-client.md, ../theme-and-css.md]
5
+ updated: 2026-07-14
6
+ ---
7
+ # template-kit/no-hex
8
+
9
+ > No hard-coded colours in section code. **Islands are not exempt** — this is the one
10
+ > rule that holds on both sides of the hydration boundary.
11
+
12
+ ## Rule
13
+
14
+ Two shapes are banned:
15
+
16
+ - **A CSS colour function**, anywhere in a string or template literal:
17
+ `oklch(…)`, `oklab(…)`, `rgb(…)`, `rgba(…)`, `hsl(…)`, `hsla(…)`, `color-mix(…)`.
18
+ No context test — nobody spells an anchor id `rgb(...)`.
19
+ - **A hex literal** (3, 4, 6, or 8 digits) **in a colour context**. A colour context is
20
+ a Tailwind arbitrary-value bracket that resolves to a colour:
21
+ - a colour property — `[color:#fff]`, `[background:#fff]`, `[background-color:#fff]`,
22
+ and the rest of `border-color`, `outline-color`, `box-shadow`, `text-shadow`,
23
+ `fill`, `stroke`, `caret-color`, `accent-color`, `text-decoration-color`;
24
+ - **any** custom property — `[--brand:#ff0000]`;
25
+ - a colour-utility prefix, with variants stripped — `bg-[#f00]`, `hover:bg-[#f00]`,
26
+ `md:dark:text-[#f00]`, `border-t-[#000]`, and the same for `ring`, `outline`,
27
+ `shadow`, `fill`, `stroke`, `decoration`, `divide`, `accent`, `caret`,
28
+ `placeholder`, `from`, `via`, `to`;
29
+ - or anywhere inside a JSX `style` attribute.
30
+
31
+ Everywhere else, a `#`-prefixed string is left alone: `href="#cafe"` is an anchor, and
32
+ `w-[#…]` is not a colour utility. `#cafe`, `#facade`, `#decade`, and `#beef` are real
33
+ anchor ids that happen to be spelled from hex digits.
34
+
35
+ Every hex-shaped token in a string is checked, not just the first — an out-of-context
36
+ token early in a `className` cannot shadow a genuine colour later in it.
37
+
38
+ ## Reason
39
+
40
+ A section carries **zero palette of its own**. It is theme-agnostic, and must look
41
+ right under every template's theme — so a baked colour breaks the first time another
42
+ template reuses the section. Keeping colour in tokens is what makes a section portable
43
+ rather than a one-off.
44
+
45
+ This is why islands get no exemption. An island is exempt from the effect, state,
46
+ handler, and inline-`style` bans because those are server-render concerns and an island
47
+ runs in the browser. A palette is not a render-path concern: the hydration boundary is
48
+ not a palette boundary.
49
+
50
+ ## Fix
51
+
52
+ Use a semantic token utility (`bg-surface-alt`, `text-ink`). If the value is genuinely
53
+ brand decoration with no token, add it to the **template's** `theme.ts` palette and use
54
+ the `--tr-color-<name>` utility it compiles to — which keeps the value in one place
55
+ instead of in one section.
56
+
57
+ ### Before
58
+
59
+ ```tsx
60
+ import { Section } from "@homepages/template-kit";
61
+
62
+ import type { Props } from "./schema.js";
63
+
64
+ export function Renderer({ slots }: Props) {
65
+ return (
66
+ <Section>
67
+ <div className="bg-[#f5f5f5]">
68
+ <h2 style={{ color: "#1a1a1a" }}>{slots.headline}</h2>
69
+ </div>
70
+ </Section>
71
+ );
72
+ }
73
+ ```
74
+
75
+ ### After
76
+
77
+ ```tsx
78
+ import { Section } from "@homepages/template-kit";
79
+
80
+ import type { Props } from "./schema.js";
81
+
82
+ export function Renderer({ slots }: Props) {
83
+ return (
84
+ <Section>
85
+ <div className="bg-surface-alt">
86
+ <h2 className="text-ink">{slots.headline}</h2>
87
+ </div>
88
+ </Section>
89
+ );
90
+ }
91
+ ```
92
+
93
+ ## See also
94
+
95
+ - [Theme and CSS](../theme-and-css.md#color-roles) — the token system and the colour roles.
96
+ - [Server vs client](server-vs-client.md) — why the other bans stop at the hydration
97
+ boundary and this one does not.
@@ -0,0 +1,121 @@
1
+ ---
2
+ purpose: The template-kit/no-inline-style authoring rule — what it enforces, why, and how to fix it.
3
+ status: living
4
+ related: [INDEX.md, server-vs-client.md, ../theme-and-css.md]
5
+ updated: 2026-07-14
6
+ ---
7
+ # template-kit/no-inline-style
8
+
9
+ > No inline `style=` in server-rendered section JSX. Islands are exempt — but an island
10
+ > is **not** exempt from [`no-hex`](no-hex.md).
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 **any JSX attribute named `style`, on any element**.
19
+ The value is never inspected: a static object, a computed one, a spread, a string — all
20
+ the same. There is no "but this one is fine" case in a server-rendered file.
21
+
22
+ **The island exemption does not extend to colour.** `no-hex` reads inside `style` values
23
+ and has no island exemption at all, so this still fails on a `"use client"` file:
24
+
25
+ ```tsx
26
+ "use client";
27
+
28
+ export default function Badge() {
29
+ return <div style={{ color: "#ff0000" }} />; // template-kit/no-hex
30
+ }
31
+ ```
32
+
33
+ Exempt from *this* rule; still a hard-coded colour.
34
+
35
+ ## Reason
36
+
37
+ Three concrete failures, all of them server-render concerns:
38
+
39
+ - **It bypasses the cascade.** An inline style is the highest-specificity declaration
40
+ short of `!important`, so a template's theme can no longer restyle the section. The
41
+ section stops being portable.
42
+ - **It skips Tailwind's responsive prefixes.** `style={{ padding: 16 }}` has no
43
+ breakpoint form; there is no way to say "16 on mobile, 48 on desktop" in it. Utilities
44
+ have exactly that (`p-4 lg:p-12`), which is why the layout ladder lives in classes.
45
+ - **It churns per-call identity** that the editor's hash-based diff cannot dedupe — the
46
+ editor re-renders the section on every keystroke, and a fresh style object each render
47
+ is work it cannot skip.
48
+
49
+ An island computing a transform from live pointer state has none of these problems: it
50
+ runs in the browser, it is not part of the cascade the theme is styling, and that is
51
+ precisely what an island is for. So it is exempt.
52
+
53
+ ## Fix
54
+
55
+ Use Tailwind utility classes. For a value that only exists at runtime, move the markup
56
+ into a `"use client"` island, where `style` is allowed.
57
+
58
+ ### Before
59
+
60
+ ```tsx
61
+ import { Section } from "@homepages/template-kit";
62
+
63
+ import type { Props } from "./schema.js";
64
+
65
+ export function Renderer({ slots }: Props) {
66
+ return (
67
+ <Section>
68
+ <div style={{ padding: 16, display: "flex", gap: 8 }}>
69
+ <h2>{slots.headline}</h2>
70
+ </div>
71
+ </Section>
72
+ );
73
+ }
74
+ ```
75
+
76
+ ### After
77
+
78
+ ```tsx
79
+ import { Section } from "@homepages/template-kit";
80
+
81
+ import type { Props } from "./schema.js";
82
+
83
+ export function Renderer({ slots }: Props) {
84
+ return (
85
+ <Section>
86
+ <div className="flex gap-2 p-4">
87
+ <h2>{slots.headline}</h2>
88
+ </div>
89
+ </Section>
90
+ );
91
+ }
92
+ ```
93
+
94
+ The runtime-value case — a transform driven by live state — has no utility form, and is
95
+ the case the island exemption exists for:
96
+
97
+ ```tsx
98
+ // sections/hero/Parallax.tsx
99
+ "use client";
100
+
101
+ import { useState } from "react";
102
+ import type { ReactNode } from "react";
103
+
104
+ export default function Parallax({ children }: { children: ReactNode }) {
105
+ const [offset, setOffset] = useState(0);
106
+ return (
107
+ <div
108
+ onPointerMove={(e) => setOffset(e.clientX / 40)}
109
+ style={{ transform: `translateX(${offset}px)` }}
110
+ >
111
+ {children}
112
+ </div>
113
+ );
114
+ }
115
+ ```
116
+
117
+ ## See also
118
+
119
+ - [Server vs client](server-vs-client.md) — the island exemption, and the one rule that
120
+ does not take it.
121
+ - [Theme and CSS](../theme-and-css.md) — the token utilities and the breakpoint ladder.
@@ -0,0 +1,100 @@
1
+ ---
2
+ purpose: The template-kit/no-nondeterminism authoring rule — what it enforces, why, and how to fix it.
3
+ status: living
4
+ related: [INDEX.md, server-vs-client.md]
5
+ updated: 2026-07-14
6
+ ---
7
+ # template-kit/no-nondeterminism
8
+
9
+ > No clock reads and no randomness in server-rendered code. Islands are exempt.
10
+
11
+ ## Rule
12
+
13
+ Whether a file is server-rendered or an island is decided per file, by the `"use client"`
14
+ directive — see [server vs client](server-vs-client.md). On an island this rule installs
15
+ no visitors at all.
16
+
17
+ In a server-rendered file, two shapes are banned:
18
+
19
+ - **A read of a nondeterministic source**: `Date.now`, `Math.random`,
20
+ `crypto.randomUUID`, `crypto.getRandomValues`, `performance.now`. The member expression
21
+ alone is enough — `Date.now` reports even without a call, because handing the function
22
+ itself somewhere is the same read one hop later.
23
+ - **`new Date()` with no arguments.** That reads the clock.
24
+
25
+ Two carve-outs:
26
+
27
+ - **`new Date(props.listed_at)` is fine.** It parses a value the section was *given*,
28
+ which is perfectly deterministic. Only the zero-argument form reads the clock.
29
+ - **Your own definitions are yours.** If the object name resolves to a real local
30
+ declaration — `const performance = { now: () => 1 };` — it is not the global, and the
31
+ rule leaves it alone.
32
+
33
+ ## Reason
34
+
35
+ The publisher and the editor both run a section's `Renderer`, and they must produce
36
+ **byte-identical HTML** from the same props. A clock read or a random value makes the two
37
+ renders disagree: the section's output is hashed, so a `Date.now()` in the markup means
38
+ the section looks changed on every single render — a fresh version each publish, and an
39
+ editor canvas that never settles.
40
+
41
+ An island does not have this problem. It runs in the browser, after hydration, and its
42
+ output is nobody's content hash. That is why it is exempt.
43
+
44
+ ## Fix
45
+
46
+ Compute the value **at fill time** and pass it in as a slot value — that is the right
47
+ route for anything that is content. Reach for an island only when the value is genuinely
48
+ live (a countdown, a "time since listed" that must tick).
49
+
50
+ ### Before
51
+
52
+ ```tsx
53
+ import { Section, Slot } from "@homepages/template-kit";
54
+
55
+ import type { Props } from "./schema.js";
56
+
57
+ export function Renderer({ slots }: Props) {
58
+ return (
59
+ <Section>
60
+ <Slot id="headline" as="h1" textLeaf>
61
+ <span>{slots.headline}</span>
62
+ </Slot>
63
+ <p>© {new Date().getFullYear()} — all rights reserved</p>
64
+ </Section>
65
+ );
66
+ }
67
+ ```
68
+
69
+ ### After
70
+
71
+ The year is content, so it becomes a slot the fill pipeline supplies. Declare it in
72
+ `schema.ts`, and the Renderer just renders what it was given:
73
+
74
+ ```tsx
75
+ import { Section, Slot } from "@homepages/template-kit";
76
+
77
+ import type { Props } from "./schema.js";
78
+
79
+ export function Renderer({ slots }: Props) {
80
+ return (
81
+ <Section>
82
+ <Slot id="headline" as="h1" textLeaf>
83
+ <span>{slots.headline}</span>
84
+ </Slot>
85
+ <Slot id="copyright_year" textLeaf>
86
+ <p>© {slots.copyright_year} — all rights reserved</p>
87
+ </Slot>
88
+ </Section>
89
+ );
90
+ }
91
+ ```
92
+
93
+ If the value must be live in the browser rather than baked at fill time, move that
94
+ markup into a `"use client"` component in the section folder instead, and render it from
95
+ the Renderer — the rule does not run there.
96
+
97
+ ## See also
98
+
99
+ - [Server vs client](server-vs-client.md) — how a file is scoped, and what an island may do.
100
+ - [Schema system](../schema-system.md) — declaring the slot the value arrives in.
@@ -0,0 +1,100 @@
1
+ ---
2
+ purpose: The template-kit/no-raw-element authoring rule — what it enforces, why, and how to fix it.
3
+ status: living
4
+ related: [INDEX.md, ../primitives.md]
5
+ updated: 2026-07-14
6
+ ---
7
+ # template-kit/no-raw-element
8
+
9
+ > **Exactly two** raw elements are banned: `<section>` and `<img>`. Every other tag is
10
+ > yours.
11
+
12
+ ## Rule
13
+
14
+ Two tags, and no others:
15
+
16
+ | Raw tag | Use instead |
17
+ |---|---|
18
+ | `<section>` | `<Section>` |
19
+ | `<img>` | `<Image>` |
20
+
21
+ That is the entire rule. **`<a>`, `<h1>`–`<h6>`, `<div>`, `<ul>`, `<button>`, `<picture>`,
22
+ `<figure>` — every other element is yours to write.** Presentation is the template's own
23
+ JSX; the kit is not trying to own your markup. If you have concluded "the kit bans raw
24
+ HTML", that is exactly backwards.
25
+
26
+ There is no island exemption: the two tags are banned in a `"use client"` file too.
27
+
28
+ ## Reason
29
+
30
+ Each of the two banned tags has a **contract-bearing primitive that must emit it**, and
31
+ writing the raw tag skips the contract.
32
+
33
+ - **`<Section>`** emits `<section class="tr-section">`, which is full-bleed by contract:
34
+ 100% width, zero padding, zero margin, so consecutive sections butt edge-to-edge and a
35
+ page composes as a clean vertical stack. A raw `<section>` lacks that class, so the
36
+ page grows gaps the template never asked for.
37
+ - **`<Image>`** owns the responsive `<picture>` (per-format AVIF/WebP `srcset`, the
38
+ mobile crop, `sizes`), the aspect-ratio box, and the neutral fallback rect that keeps
39
+ the layout box when `src` is empty. A raw `<img>` with a null `src` is a collapsed box
40
+ or a broken-image icon on a real customer's page — and a missing image is a **supported
41
+ state**, not a bug: it happens whenever a listing is thin. `<Image slotId>` also emits
42
+ the slot marker itself, so the image stays selectable in the editor.
43
+
44
+ The two tags are banned because they are the two the platform has to be able to trust.
45
+ Everything else carries no contract, so nothing is gained by policing it.
46
+
47
+ ## Fix
48
+
49
+ Import the primitive from the package root and use it:
50
+
51
+ ```tsx
52
+ import { Section, Image } from "@homepages/template-kit";
53
+ ```
54
+
55
+ ### Before
56
+
57
+ ```tsx
58
+ import type { Props } from "./schema.js";
59
+
60
+ export function Renderer({ slots }: Props) {
61
+ return (
62
+ <section>
63
+ <h2>{slots.headline}</h2>
64
+ <img src={slots.hero_image.url ?? ""} alt={slots.hero_image.alt} />
65
+ </section>
66
+ );
67
+ }
68
+ ```
69
+
70
+ ### After
71
+
72
+ ```tsx
73
+ import { Image, Section } from "@homepages/template-kit";
74
+
75
+ import type { Props } from "./schema.js";
76
+
77
+ export function Renderer({ slots }: Props) {
78
+ return (
79
+ <Section>
80
+ <h2>{slots.headline}</h2>
81
+ <Image
82
+ slotId="hero_image"
83
+ src={slots.hero_image.url}
84
+ alt={slots.hero_image.alt}
85
+ responsive={slots.hero_image.responsive}
86
+ aspectRatio="5 / 3"
87
+ />
88
+ </Section>
89
+ );
90
+ }
91
+ ```
92
+
93
+ The `<h2>` stays exactly as it was. `Image` takes `src`/`alt` directly — a `null` or empty
94
+ `src` is a supported state it renders a fallback rect for, so no `?? ""` guard is needed.
95
+
96
+ ## See also
97
+
98
+ - [Contract primitives](../primitives.md) — all five primitives and the markers they emit.
99
+ - [`image-bare-needs-reason`](image-bare-needs-reason.md) — the escape hatch that drops
100
+ `<Image>`'s frame, and what it costs.