@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,95 @@
1
+ ---
2
+ purpose: The template-kit/size-section-css authoring rule — what it enforces, why, and how to fix it.
3
+ status: living
4
+ related: [INDEX.md, size-renderer-bundle.md, ../theme-and-css.md]
5
+ updated: 2026-07-14
6
+ ---
7
+ # template-kit/size-section-css
8
+
9
+ > A section's compiled CSS must be **≤ 50 KB gzip** — **including the package CSS its module
10
+ > graph imports.** That inclusion is the whole point of the rule.
11
+
12
+ ## Rule
13
+
14
+ A section's compiled cascade layer must gzip to **50 KB or less**. What is measured is
15
+ everything that lands in the layer:
16
+
17
+ - the section's hand-written CSS, and
18
+ - **every package stylesheet its module graph imports** — the `import "swiper/css"` in a
19
+ component, an island, or a marked enhancer.
20
+
21
+ The budget is **visible in `template-kit check`'s configuration and is not
22
+ author-overridable**. A workspace cannot raise it, and there is no per-section exemption.
23
+
24
+ ## Reason
25
+
26
+ Same page, same phone, same buyer as
27
+ [`size-renderer-bundle`](size-renderer-bundle.md) — and here the bytes are worse, because
28
+ CSS blocks the first paint.
29
+
30
+ "Including package CSS" is not a technicality; it is the failure this rule exists to catch.
31
+ Your own stylesheet is almost never the problem — it is a few kilobytes of overrides. The
32
+ package's is. And the reason it is invisible to you is the mechanism: a package stylesheet's
33
+ `url()` references — its icon sprites, its webfont — are **inlined as `data:` URIs** into the
34
+ compiled stylesheet. A 300 KB font file arrives in your published CSS without you ever
35
+ having seen a 300 KB file. Nothing in your source looks large. Nothing in the package's
36
+ `dist` listing looks alarming. The compiled layer is 400 KB.
37
+
38
+ So the number this check reports is frequently the first time anyone finds out what a
39
+ one-line CSS import actually cost.
40
+
41
+ ## Fix
42
+
43
+ Three moves, in order of how often they work:
44
+
45
+ 1. **Import the slimmest entry the package offers.** Most CSS-shipping packages have a core
46
+ stylesheet plus opt-in modules, *and* a fat "everything" bundle that is the one you get
47
+ from copying the quickstart. Take the core, plus only the modules you use.
48
+ 2. **Cut the webfont.** If the package's stylesheet drags in a font for its icons, and you
49
+ are using three of those icons, replace them with inline SVG — or with a family the
50
+ template's theme already declares in `fonts` (`--tr-font-<name>`), which costs nothing
51
+ because it is already loaded.
52
+ 3. **Drop the package and hand-write the residue.** If you use one component from a large
53
+ widget library, its stylesheet is paying for components you never render.
54
+
55
+ ### Before
56
+
57
+ ```tsx
58
+ // sections/gallery/Carousel.tsx
59
+ "use client";
60
+
61
+ import Swiper from "swiper";
62
+ import "swiper/swiper-bundle.css"; // ← every module Swiper ships, plus its icon font
63
+
64
+ export default function Carousel({ slides }: { slides: { id: string; url: string }[] }) {
65
+ // …uses navigation arrows and pagination dots, and nothing else
66
+ }
67
+ ```
68
+
69
+ ### After
70
+
71
+ ```tsx
72
+ // sections/gallery/Carousel.tsx
73
+ "use client";
74
+
75
+ import Swiper from "swiper";
76
+ import "swiper/css"; // ← the core layout only
77
+ import "swiper/css/navigation"; // ← the two modules this carousel actually uses
78
+ import "swiper/css/pagination";
79
+
80
+ export default function Carousel({ slides }: { slides: { id: string; url: string }[] }) {
81
+ // …
82
+ }
83
+ ```
84
+
85
+ The arrows and dots that came from the bundle's icon font are drawn as inline SVG in the
86
+ component instead, so nothing pulls a webfont into the layer.
87
+
88
+ ## See also
89
+
90
+ - [`size-renderer-bundle`](size-renderer-bundle.md) — the same budget on the section's
91
+ JavaScript.
92
+ - [`no-bare-css-import`](no-bare-css-import.md) — why a package's stylesheet is imported from
93
+ code and never `@import`ed from CSS.
94
+ - [Theme and CSS](../theme-and-css.md) — the fonts and tokens the page already loads, which
95
+ cost you nothing to reuse.
@@ -0,0 +1,127 @@
1
+ ---
2
+ purpose: The template-kit/slot-group-marker authoring rule — what it enforces, why, and how to fix it.
3
+ status: living
4
+ related: [INDEX.md, ../primitives.md, ../schema-system.md]
5
+ updated: 2026-07-14
6
+ ---
7
+ # template-kit/slot-group-marker
8
+
9
+ > A group declared in `meta.groups` with **two or more members** must be wrapped in
10
+ > `<SlotGroup id="…">` in the JSX. Grouping is authored in two places, and they must agree.
11
+
12
+ ## Rule
13
+
14
+ For every entry in `schema.ts`'s `meta.groups` whose flattened member list has **≥ 2
15
+ members**, the section's JSX must contain `<SlotGroup id="<that group's id>">` wrapping a
16
+ container that is a **shared ancestor** of every member's marker.
17
+
18
+ "Flattened" is literal: `members: ["agent_name", ["agent_email", "agent_phone"]]` is three
19
+ members, not two — a `[a, b]` tuple is a 2-up row, not a single member.
20
+
21
+ Membership is read from the typed `meta.groups` block. The check never guesses a group by
22
+ scanning your markup for things that look related.
23
+
24
+ Carve-outs:
25
+
26
+ - **A 1-member group needs no wrapper.** One slot, one card — the group adds no grouping
27
+ the marker isn't already doing.
28
+ - **A single slot rendered more than once is not a group.** An address shown in both a
29
+ desktop and a mobile layout is one slot in two places; it needs no `meta.groups` entry
30
+ and no `<SlotGroup>`.
31
+
32
+ ## Reason
33
+
34
+ `meta.groups` makes the editor collapse several slots into **one accordion card** in the
35
+ sidebar. The `<SlotGroup>` wrapper is the other half of that promise: the canvas outlines
36
+ and selects the group as **one unit**, because its hover/selection chrome targets the
37
+ `data-slot-group` container.
38
+
39
+ Declare the group and omit the wrapper and the two halves disagree. The sidebar shows one
40
+ card, and the canvas — with no group container to find — falls back to outlining the
41
+ members one at a time. The user sees a single "Agent" card that highlights three separate
42
+ boxes on the page.
43
+
44
+ `SlotGroup` must be an **ancestor** of every member's `data-slot-id` element, because the
45
+ editor resolves the group with `closest("[data-slot-group]")` from the clicked slot. A
46
+ marker that sits beside the members rather than around them is never found.
47
+
48
+ ## Fix
49
+
50
+ Wrap the members' shared container in `<SlotGroup id="…">`, using the **same id** as the
51
+ `meta.groups` entry. If the slots do not actually edit as a unit, the other honest fix is
52
+ to delete the group from `meta.groups` — then each slot is its own card and no wrapper is
53
+ wanted.
54
+
55
+ ### Before
56
+
57
+ ```ts
58
+ // sections/contact/schema.ts
59
+ import type { SectionProps, SectionSchemaShape } from "@homepages/template-kit";
60
+
61
+ export const schema = {
62
+ meta: {
63
+ displayName: "Contact",
64
+ groups: [
65
+ { id: "agent", label: "Agent", members: ["agent_name", ["agent_email", "agent_phone"]] },
66
+ ],
67
+ },
68
+ slots: {
69
+ agent_name: { type: "text", size: "short", required: true, editable_by_user: true },
70
+ agent_email: { type: "text", size: "short", required: false, editable_by_user: true },
71
+ agent_phone: { type: "text", size: "short", required: false, editable_by_user: true },
72
+ },
73
+ } as const satisfies SectionSchemaShape;
74
+
75
+ export type Props = SectionProps<typeof schema>;
76
+ ```
77
+
78
+ ```tsx
79
+ // sections/contact/Renderer.tsx — template-kit/slot-group-marker: agent
80
+ import { Section, Slot } from "@homepages/template-kit";
81
+
82
+ import type { Props } from "./schema.js";
83
+
84
+ export function Renderer({ slots }: Props) {
85
+ return (
86
+ <Section>
87
+ <div className="flex flex-col gap-2">
88
+ <Slot id="agent_name" textLeaf><h3>{slots.agent_name}</h3></Slot>
89
+ <Slot id="agent_email" textLeaf><span>{slots.agent_email}</span></Slot>
90
+ <Slot id="agent_phone" textLeaf><span>{slots.agent_phone}</span></Slot>
91
+ </div>
92
+ </Section>
93
+ );
94
+ }
95
+ ```
96
+
97
+ ### After
98
+
99
+ The container was already there — it just needed to be the group:
100
+
101
+ ```tsx
102
+ // sections/contact/Renderer.tsx
103
+ import { Section, Slot, SlotGroup } from "@homepages/template-kit";
104
+
105
+ import type { Props } from "./schema.js";
106
+
107
+ export function Renderer({ slots }: Props) {
108
+ return (
109
+ <Section>
110
+ <SlotGroup id="agent" className="flex flex-col gap-2">
111
+ <Slot id="agent_name" textLeaf><h3>{slots.agent_name}</h3></Slot>
112
+ <Slot id="agent_email" textLeaf><span>{slots.agent_email}</span></Slot>
113
+ <Slot id="agent_phone" textLeaf><span>{slots.agent_phone}</span></Slot>
114
+ </SlotGroup>
115
+ </Section>
116
+ );
117
+ }
118
+ ```
119
+
120
+ ## See also
121
+
122
+ - [Contract primitives: `SlotGroup`](../primitives.md#slotgroup--slots-edited-as-one-unit) —
123
+ the ancestor requirement and what the marker does.
124
+ - [The schema system](../schema-system.md#grouping-slots-into-one-editor-card) — grouping is
125
+ declared in `meta.groups`; there is no per-slot `group` field.
126
+ - [`missing-slot-marker`](missing-slot-marker.md) — the per-slot marker each member still
127
+ needs.
@@ -0,0 +1,148 @@
1
+ ---
2
+ purpose: The template-kit/slot-marker-literal 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/slot-marker-literal
8
+
9
+ > A slot key must be written as a string literal wherever it is marked. `SlotItem` is
10
+ > exempt.
11
+
12
+ ## Rule
13
+
14
+ These four places carry a slot key, and each must be a **string literal**:
15
+
16
+ | Where | Example |
17
+ |---|---|
18
+ | `<Slot id>` | `<Slot id="headline">` |
19
+ | `<SlotGroup id>` | `<SlotGroup id="agent">` |
20
+ | `slotId` on any element | `<Image slotId="hero_image" …>` |
21
+ | `data-slot-id` / `data-slot-group` on any element | `<div data-slot-id="headline">` |
22
+
23
+ Anything that is not a plain string literal reports: an expression (`<Slot id={key}>`), a
24
+ template literal (`` <Slot id={`slot_${n}`}> ``), and a valueless attribute
25
+ (`<div data-slot-id>`).
26
+
27
+ **`SlotItem` is deliberately exempt.** Its prop is `index: number` — an array position,
28
+ written `index={i}` inside a `.map()`, not a schema key. There is nothing to cross-check
29
+ it against, so an expression there is correct and expected.
30
+
31
+ ## Reason
32
+
33
+ This is not style. A separate, cross-file completeness check verifies that **every slot
34
+ declared in `schema.ts` has a marker in the section's JSX** — and it reads these keys
35
+ **statically**, from the source text, without running your Renderer.
36
+
37
+ A key written as an expression is a key it cannot resolve. So the check cannot see the
38
+ slot as marked, and — worse — the marker that actually reaches the browser is whatever
39
+ your expression happened to evaluate to. The slot **silently loses editor selection**: it
40
+ still fills, it still renders, and a user clicking on it in the editor selects nothing.
41
+ Nothing throws. The failure is invisible until someone tries to edit that block.
42
+
43
+ This rule exists **so that** [`missing-slot-marker`](missing-slot-marker.md) can work. A
44
+ `.map()`-generated slot key defeats both at once: the completeness check reports the slot
45
+ as unmarked, and no lint tells you why.
46
+
47
+ ## Fix
48
+
49
+ This shows up in two different shapes, and the fix depends on which one you're in.
50
+
51
+ **A key that is static but not literal** — held in a `const`, or assembled with a
52
+ template literal — isn't generating anything: there is exactly one possible value, so
53
+ unwrap it back into a plain inline string literal.
54
+
55
+ **A key generated inside a `.map()` over an array** is a genuine collection: declare it
56
+ as one slot in `schema.ts`, mark the array **once** with `<Slot>`, and mark each row with
57
+ `<SlotItem index={i}>`.
58
+
59
+ ### Before
60
+
61
+ ```tsx
62
+ import { Section, Slot } from "@homepages/template-kit";
63
+
64
+ import type { Props } from "./schema.js";
65
+
66
+ const FIELD = "headline";
67
+
68
+ export function Renderer({ slots }: Props) {
69
+ return (
70
+ <Section>
71
+ <Slot id={FIELD} as="h1" textLeaf>
72
+ <span>{slots.headline}</span>
73
+ </Slot>
74
+ <Slot id={`sub${"title"}`} textLeaf>
75
+ <p>{slots.subtitle}</p>
76
+ </Slot>
77
+ </Section>
78
+ );
79
+ }
80
+ ```
81
+
82
+ Neither `FIELD` nor the template literal is doing any work — both spell out their whole
83
+ value at the call site. That's the common case: a constant or a template literal standing
84
+ in for a string that was always going to be one string.
85
+
86
+ ### After
87
+
88
+ ```tsx
89
+ import { Section, Slot } from "@homepages/template-kit";
90
+
91
+ import type { Props } from "./schema.js";
92
+
93
+ export function Renderer({ slots }: Props) {
94
+ return (
95
+ <Section>
96
+ <Slot id="headline" as="h1" textLeaf>
97
+ <span>{slots.headline}</span>
98
+ </Slot>
99
+ <Slot id="subtitle" textLeaf>
100
+ <p>{slots.subtitle}</p>
101
+ </Slot>
102
+ </Section>
103
+ );
104
+ }
105
+ ```
106
+
107
+ The other shape is different: a key actually produced by iterating an array is modelling
108
+ a real collection, and the fix is structural, not a matter of unwrapping.
109
+
110
+ ```tsx
111
+ // Before — `.map()` produces a different key each iteration; none of them is a literal.
112
+ const FIELDS = ["headline", "subhead"] as const;
113
+
114
+ export function Renderer({ slots }: Props) {
115
+ return (
116
+ <Section>
117
+ {FIELDS.map((key) => (
118
+ <Slot key={key} id={key} textLeaf>
119
+ <p>{slots[key]}</p>
120
+ </Slot>
121
+ ))}
122
+ </Section>
123
+ );
124
+ }
125
+ ```
126
+
127
+ A genuine collection keeps its loop — the literal key is on the `<Slot>`, and the loop
128
+ variable stays on `SlotItem`'s `index`, which is exempt:
129
+
130
+ ```tsx
131
+ <Slot id="amenities">
132
+ {slots.amenities.map((item, i) => (
133
+ <SlotItem key={item.id} index={i} as="li">
134
+ <h3>{item.label}</h3>
135
+ </SlotItem>
136
+ ))}
137
+ </Slot>
138
+ ```
139
+
140
+ Don't reach for this structural rewrite for the first case above — a named constant or a
141
+ template literal is not a collection, and modelling it as one is solving a problem you
142
+ don't have.
143
+
144
+ ## See also
145
+
146
+ - [Contract primitives](../primitives.md#the-marker-contract) — the marker attributes and
147
+ which primitive emits each.
148
+ - [Schema system](../schema-system.md) — where slot keys are declared.
@@ -0,0 +1,103 @@
1
+ ---
2
+ purpose: The template-kit/typecheck 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/typecheck
8
+
9
+ > The template must compile. Every type error is one violation.
10
+
11
+ ## Rule
12
+
13
+ The template's TypeScript must compile cleanly. The check resolves `typescript` from **your
14
+ workspace's** `node_modules` — the compiler that runs is the one you installed, at the
15
+ version you pinned, against your own `tsconfig.json`.
16
+
17
+ If `typescript` is not installed, that is itself the diagnostic (with the fix), not a crash.
18
+
19
+ ## Reason
20
+
21
+ The schema system is the section contract, and it is enforced **through the type system**.
22
+ `SectionProps<typeof schema>` is not a convenience — it is the mechanism that keeps
23
+ `schema.ts` and `Renderer.tsx` in agreement: a slot you add to the schema and forget to
24
+ render, a slot you render under the wrong name, an optional slot you treat as always
25
+ present, a slot whose type you assume wrong. Each of those is a compile error rather than a
26
+ surprise on a stranger's published page.
27
+
28
+ A template that does not typecheck has opted out of the only thing checking that the two
29
+ files still describe the same section.
30
+
31
+ ## Fix
32
+
33
+ Fix the type error. If the compiler itself is missing, install it and point your
34
+ `tsconfig.json` at the kit's base config, which sets the module resolution and JSX settings
35
+ the kit's types expect:
36
+
37
+ ```jsonc
38
+ // tsconfig.json
39
+ { "extends": "@homepages/template-kit/tsconfig", "include": ["sections", "theme.ts"] }
40
+ ```
41
+
42
+ ```bash
43
+ npm install --save-dev typescript
44
+ ```
45
+
46
+ ### Before
47
+
48
+ `year_built` is declared `required: false`, so `SectionProps` resolves it to
49
+ `string | null` — and the Renderer treats it as a `string`:
50
+
51
+ ```tsx
52
+ // sections/facts/Renderer.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
+ {/* error TS18047: 'slots.year_built' is possibly 'null'. */}
61
+ <Slot id="year_built" textLeaf>
62
+ <p>Built {slots.year_built.slice(0, 4)}</p>
63
+ </Slot>
64
+ </Section>
65
+ );
66
+ }
67
+ ```
68
+
69
+ That is not a pedantic complaint. `required: false` means a thin listing really does arrive
70
+ with no year, and this line really does throw on it.
71
+
72
+ ### After
73
+
74
+ ```tsx
75
+ // sections/facts/Renderer.tsx
76
+ import { Section, Slot } from "@homepages/template-kit";
77
+
78
+ import type { Props } from "./schema.js";
79
+
80
+ export function Renderer({ slots }: Props) {
81
+ return (
82
+ <Section>
83
+ {slots.year_built ? (
84
+ <Slot id="year_built" textLeaf>
85
+ <p>Built {slots.year_built.slice(0, 4)}</p>
86
+ </Slot>
87
+ ) : null}
88
+ </Section>
89
+ );
90
+ }
91
+ ```
92
+
93
+ If the slot should never be absent, the other correct fix is in the schema — declare it
94
+ `required: true`, and the type narrows to `string` everywhere.
95
+
96
+ ## See also
97
+
98
+ - [The schema system](../schema-system.md#renderertsx--props-derived-never-hand-written) —
99
+ how `SectionProps` resolves each slot to its runtime type.
100
+ - [`props-from-schema`](props-from-schema.md) — the rule that stops a hand-written `Props`
101
+ from disabling this inference in the first place.
102
+ - [`render-invariant`](render-invariant.md) — the same class of bug caught at render time,
103
+ for the cases the types cannot see.
@@ -0,0 +1,113 @@
1
+ ---
2
+ purpose: The template-kit/unlayered-fence authoring rule — what it enforces, why, and how to fix it.
3
+ status: living
4
+ related: [INDEX.md, ../theme-and-css.md]
5
+ updated: 2026-07-14
6
+ ---
7
+ # template-kit/unlayered-fence
8
+
9
+ > **This is not "you forgot to layer your CSS."** Everything is layered by default — the
10
+ > build does it for you. This rule governs the *escape hatch* from layering: an
11
+ > `/* unlayered: … */` fence must give a reason and must be closed.
12
+
13
+ ## Rule
14
+
15
+ Your hand-written CSS is wrapped in a cascade layer by the build. You do not write
16
+ `@layer` yourself, and there is nothing to forget.
17
+
18
+ The **fence** opts a run of rules *out* of that wrapping, hoisting them above every layer:
19
+
20
+ ```css
21
+ /* unlayered: <reason> */
22
+ …rules that must beat every layer…
23
+ /* endunlayered */
24
+ ```
25
+
26
+ Two things are checked, both under this id:
27
+
28
+ 1. **Every opener carries a non-empty reason.** A bare `/* unlayered: */` is a violation.
29
+ 2. **Openers and closers balance.** An unterminated fence is a violation — it would swallow
30
+ the whole rest of the file out of the layer, which is never what anyone meant.
31
+
32
+ ## Reason
33
+
34
+ Cascade layers have one rule that decides everything here: **an unlayered rule beats a
35
+ layered one, regardless of specificity.** Not "usually" — always. That is what layers are
36
+ for, and it is why the platform layers your CSS: inside the layer order (`theme, base,
37
+ components, sections, template, utilities`) a Tailwind utility can always override a
38
+ section's stylesheet, and a section's stylesheet can never leak past its layer to beat
39
+ someone else's utility.
40
+
41
+ So the hatch exists for exactly one situation: **a third-party library injects an unlayered
42
+ rule into the document at runtime** — a map SDK writing a `<style>` element when it mounts,
43
+ say — and that rule beats your layered override no matter how specific you make it. The
44
+ only thing that can beat it is a rule that is also unlayered. Nothing else qualifies. If
45
+ your rule merely needs to beat *your own* CSS, or a Tailwind utility, it belongs in a layer
46
+ and you are reaching for the wrong tool.
47
+
48
+ Which is why the reason is mandatory. A fence with no reason is a rule that outranks every
49
+ layer on the page for reasons the next author cannot reconstruct — they cannot tell whether
50
+ it is load-bearing or whether someone was fighting a specificity problem with a hammer. And
51
+ an unbalanced fence silently promotes every rule below it, so a stylesheet that behaved
52
+ yesterday starts beating the utilities that were supposed to override it.
53
+
54
+ ## Fix
55
+
56
+ Close every fence with `/* endunlayered */`, and give every opener a reason that **names the
57
+ runtime-injected rule it has to beat**. A short phrase is enough — it only has to be
58
+ non-empty and specific about which rule it's beating, not a full sentence. If it does not
59
+ have to beat one, delete the fence: layered is the default, and the default is right.
60
+
61
+ The Before/After below both open with a `/* css-reason: … */` comment. That line has
62
+ nothing to do with fencing — it satisfies a separate rule, [`css-reason`](css-reason.md),
63
+ which every hand-written stylesheet must open with. It's shown here because real section
64
+ CSS carries both; this rule's own grammar starts at `/* unlayered: */`.
65
+
66
+ ### Before
67
+
68
+ ```css
69
+ /* css-reason: overrides the map SDK's injected control chrome. */
70
+
71
+ /* unlayered: */
72
+ .maplibregl-ctrl-group button {
73
+ background: var(--tr-color-surface);
74
+ border-radius: var(--tr-radius-sm);
75
+ }
76
+
77
+ .tr-map-legend {
78
+ padding: 0.5rem;
79
+ }
80
+ ```
81
+
82
+ Two violations at once: the reason is empty, and the fence is never closed — so
83
+ `.tr-map-legend` is hoisted out of the layer too, where it now outranks every utility a
84
+ future author might try to override it with.
85
+
86
+ ### After
87
+
88
+ ```css
89
+ /* css-reason: overrides the map SDK's control chrome — the SDK builds those nodes after
90
+ hydration, so Tailwind's scanner never sees a class to compile. */
91
+
92
+ /* unlayered: the map SDK injects its control styles as an unlayered <style> element at
93
+ runtime; a layered rule cannot beat it at any specificity. */
94
+ .maplibregl-ctrl-group button {
95
+ background: var(--tr-color-surface);
96
+ border-radius: var(--tr-radius-sm);
97
+ }
98
+ /* endunlayered */
99
+
100
+ /* Our own DOM — layered, like everything else. */
101
+ .tr-map-legend {
102
+ padding: 0.5rem;
103
+ }
104
+ ```
105
+
106
+ The fence now wraps exactly the rules that need to escape, and `.tr-map-legend` is back in
107
+ the layer where a utility can still override it.
108
+
109
+ ## See also
110
+
111
+ - [Theme and CSS](../theme-and-css.md) — the layer order, and why utilities come last.
112
+ - [`css-reason`](css-reason.md) — the comment every hand-written stylesheet opens with; the
113
+ fence reason is the same discipline, one level in.