@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,102 @@
1
+ ---
2
+ purpose: The template-kit/bundle-binary-asset authoring rule — what it enforces, why, and how to fix it.
3
+ status: living
4
+ related: [INDEX.md, bundle-incomplete.md, ../primitives.md]
5
+ updated: 2026-07-14
6
+ ---
7
+ # template-kit/bundle-binary-asset
8
+
9
+ > A section folder holds code, not static files. A binary asset dropped into it has no
10
+ > build meaning yet.
11
+
12
+ ## Rule
13
+
14
+ No file inside a section folder may be a binary asset: an image, a font, a video, an
15
+ audio file, an archive, a document. The check looks two ways:
16
+
17
+ - **By extension** — `.svg`, `.png`, `.jpg`/`.jpeg`, `.gif`, `.webp`, `.avif`, `.ico`,
18
+ `.woff`/`.woff2`, `.ttf`, `.otf`, `.eot`, `.mp4`, `.webm`, `.mov`, `.mp3`, `.wav`,
19
+ `.pdf`, `.zip`.
20
+ - **By content** — any other file whose first bytes contain a NUL byte, catching an
21
+ asset type the extension list doesn't name.
22
+
23
+ This is scoped to the section folder itself (`Renderer.tsx`, `schema.ts`, `components/`,
24
+ everything under it) — not to the photos a listing supplies. Those arrive at runtime
25
+ through a slot's `src`, never as a file that ships inside your section.
26
+
27
+ ## Reason
28
+
29
+ A section folder is published as source: its content hash covers every file in it, and
30
+ the platform has no pipeline yet to fingerprint, optimize, or serve a binary file that
31
+ lives alongside your code. A `.png` committed today has no URL tomorrow — there is
32
+ nowhere for it to be requested from.
33
+
34
+ Everything a section needs to draw — an icon, a divider graphic, a background pattern —
35
+ is expressible as markup instead: an inline `<svg>`, or a shape built from CSS. That
36
+ markup is text, ships with the rest of your JSX, and needs no pipeline at all.
37
+
38
+ ## Fix
39
+
40
+ Delete the binary file and inline its content as SVG markup — either directly in the
41
+ JSX that used it, or as its own small component if more than one place needs it.
42
+
43
+ ### Before
44
+
45
+ ```text
46
+ sections/hero/
47
+ Renderer.tsx
48
+ schema.ts
49
+ fill-spec.ts
50
+ fixtures.ts
51
+ assets/
52
+ arrow.svg ← binary asset in the bundle
53
+ ```
54
+
55
+ ```tsx
56
+ // sections/hero/Renderer.tsx
57
+ import arrowUrl from "./assets/arrow.svg";
58
+
59
+ export function Renderer() {
60
+ return <img src={arrowUrl} alt="" className="h-4 w-4" />;
61
+ }
62
+ ```
63
+
64
+ ### After
65
+
66
+ ```text
67
+ sections/hero/
68
+ Renderer.tsx
69
+ schema.ts
70
+ fill-spec.ts
71
+ fixtures.ts
72
+ components/
73
+ ScrollArrow.tsx ← markup, not a binary file
74
+ ```
75
+
76
+ ```tsx
77
+ // sections/hero/components/ScrollArrow.tsx
78
+ export function ScrollArrow() {
79
+ return (
80
+ <svg viewBox="0 0 16 16" className="h-4 w-4" fill="none" aria-hidden="true">
81
+ <path d="M8 3v10M3 9l5 4 5-4" stroke="currentColor" strokeWidth="1.5" />
82
+ </svg>
83
+ );
84
+ }
85
+ ```
86
+
87
+ ```tsx
88
+ // sections/hero/Renderer.tsx
89
+ import { ScrollArrow } from "./components/ScrollArrow.js";
90
+
91
+ export function Renderer() {
92
+ return <ScrollArrow />;
93
+ }
94
+ ```
95
+
96
+ ## See also
97
+
98
+ - [`bundle-incomplete`](bundle-incomplete.md) — the other rule on the folder's contents,
99
+ checked by the same pass over the section anatomy.
100
+ - [Contract primitives](../primitives.md) — `Image`, the primitive that owns every photo
101
+ a listing supplies. It reads a `src` at runtime; it is not a way to ship a binary file
102
+ inside your section.
@@ -0,0 +1,117 @@
1
+ ---
2
+ purpose: The template-kit/bundle-incomplete 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/bundle-incomplete
8
+
9
+ > A section folder is **yours**. Its only structural requirement is that four contract
10
+ > files exist at its root.
11
+
12
+ ## Rule
13
+
14
+ A section folder must contain these four, **as files, at the folder root**:
15
+
16
+ | File | Declares |
17
+ |---|---|
18
+ | `schema.ts` | the slots and options the section has |
19
+ | `fill-spec.ts` | how AI fills those slots |
20
+ | `fixtures.ts` | sample content for local preview |
21
+ | `Renderer.tsx` | the markup |
22
+
23
+ Each missing file is one violation, so a folder holding only a `Renderer.tsx` reports
24
+ three times.
25
+
26
+ Two near-misses do **not** satisfy it:
27
+
28
+ - a **directory** named `schema.ts/` — the check wants a file;
29
+ - a file at `components/schema.ts` — the four are looked for at the folder root, not
30
+ searched for down the tree.
31
+
32
+ Everything else in the folder is unconstrained. `components/`, `hooks/`, `utils/`, any
33
+ nesting, any number of support modules, islands, stylesheets, enhancers: the check has no
34
+ opinion about them, and neither does the rest of the kit.
35
+
36
+ ## Reason
37
+
38
+ The four files are the platform's entire interface to a section. The publisher reads
39
+ `schema.ts` to know what the section contains, the fill pipeline reads `fill-spec.ts` to
40
+ fill it, preview reads `fixtures.ts`, and `Renderer.tsx` is the markup. A folder missing
41
+ one of them cannot be loaded as a section.
42
+
43
+ That freedom has one non-obvious consequence, worth knowing before it surprises you:
44
+ **every file in the folder is part of the section's identity.** The content hash covers
45
+ the whole folder, not just the four — so editing a helper, a util, or even a comment
46
+ produces a new section version on the next publish, exactly as editing `Renderer.tsx`
47
+ would.
48
+
49
+ ## Fix
50
+
51
+ Create the missing file at the folder root. A section that fills nothing still needs a
52
+ `fill-spec.ts`; a section with no designed states still needs a `fixtures.ts`. Declaring
53
+ "nothing to fill" is a declaration.
54
+
55
+ ### Before
56
+
57
+ ```text
58
+ sections/hero/
59
+ Renderer.tsx
60
+ schema.ts
61
+ components/
62
+ Headline.tsx
63
+ ```
64
+
65
+ Two violations: `fill-spec.ts` and `fixtures.ts` are missing. `components/` is not one of
66
+ them — it was never a problem.
67
+
68
+ ### After
69
+
70
+ ```text
71
+ sections/hero/
72
+ Renderer.tsx
73
+ schema.ts
74
+ fill-spec.ts ← added
75
+ fixtures.ts ← added
76
+ components/
77
+ Headline.tsx
78
+ ```
79
+
80
+ ```ts
81
+ // sections/hero/fill-spec.ts
82
+ import type { FillSpecShape } from "@homepages/template-kit";
83
+
84
+ export const fillSpec = {
85
+ reads: [],
86
+ decisions: [
87
+ {
88
+ id: "headline_text",
89
+ type: "text-block",
90
+ produces: ["headline"],
91
+ length_cap: 60,
92
+ structure: "single_sentence",
93
+ voice_prompt: "Confident premium-listing voice. Title Case.",
94
+ },
95
+ ],
96
+ } as const satisfies FillSpecShape;
97
+ ```
98
+
99
+ ```ts
100
+ // sections/hero/fixtures.ts
101
+ import type { FixtureModule } from "@homepages/template-kit";
102
+
103
+ const fixtures: FixtureModule = {
104
+ base: {
105
+ slots: { headline: "A mansard Victorian in the heart of Jamaica Plain" },
106
+ },
107
+ };
108
+
109
+ export default fixtures;
110
+ ```
111
+
112
+ ## See also
113
+
114
+ - [The schema system](../schema-system.md) — what each of the four files declares, and
115
+ the types they satisfy.
116
+ - [`missing-slot-marker`](missing-slot-marker.md) — the check that runs once the four
117
+ exist: every editable slot in `schema.ts` has a marker in the JSX.
@@ -0,0 +1,94 @@
1
+ ---
2
+ purpose: The template-kit/css-reason 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/css-reason
8
+
9
+ > Hand-written CSS is the escape hatch from Tailwind, so every stylesheet must **open**
10
+ > with a comment saying what it escapes for.
11
+
12
+ ## Rule
13
+
14
+ Every hand-written `.css` file — a section's `styles.css`, any other `.css` in a section
15
+ folder, and the template's own `styles.css` — must **begin** with a block comment
16
+ containing the token `css-reason:`.
17
+
18
+ Three ways to get this wrong:
19
+
20
+ - **The comment is not first.** The token must be in the file's *leading* comment.
21
+ Mentioning `css-reason:` further down, after a rule, does not pass.
22
+ - **The comment is a `//` line.** `//` does not open a comment in CSS. It is not a comment
23
+ at all — it is a parse error waiting to happen, and it does not satisfy this check.
24
+ - **The file is in a subdirectory and you assumed it was exempt.** CSS is collected **by
25
+ path, not by import**: the build walks the whole section folder. A stylesheet at
26
+ `sections/map/styles/markers.css` is gated — and published — exactly like `styles.css`.
27
+
28
+ The token is all that is checked, but the sentence after it is the point. Write it for the
29
+ next author.
30
+
31
+ ## Reason
32
+
33
+ Tailwind is the default, and it is the default for a reason: a utility class is scoped to
34
+ the element it sits on, participates in the cascade layers the platform already orders,
35
+ and cannot leak into another section on the same page. Hand-written CSS gives all of that
36
+ up.
37
+
38
+ There is exactly one thing utilities cannot do: style **DOM that Tailwind's scanner never
39
+ sees**. Markup an enhancer builds at runtime has no class in your source for the scanner
40
+ to find, and a third-party widget's own DOM was never yours to put classes on. Those are
41
+ the cases hand-CSS exists for.
42
+
43
+ We cannot cheaply prove that a given rule is *inexpressible* as a utility — so the check
44
+ enforces the half of the bar that is checkable: **you have to say which un-scannable DOM
45
+ you are styling.** A stylesheet that cannot name one is almost always a stylesheet whose
46
+ rules should have been utility classes, and a reasonless file leaves the next author
47
+ unable to tell the difference.
48
+
49
+ ## Fix
50
+
51
+ Make the first thing in the file a block comment that names the DOM Tailwind cannot see.
52
+ If you find you cannot name one, that is the answer: delete the CSS and use utility
53
+ classes on the markup.
54
+
55
+ ### Before
56
+
57
+ ```css
58
+ /* sections/neighborhood-map/styles.css */
59
+ .tr-map-marker {
60
+ display: grid;
61
+ place-items: center;
62
+ border-radius: var(--tr-radius-full);
63
+ background: var(--tr-color-primary);
64
+ }
65
+ ```
66
+
67
+ ### After
68
+
69
+ ```css
70
+ /* css-reason: styles the marker DOM the neighborhood-map enhancer builds at runtime —
71
+ the nodes are created by the map library after hydration, so Tailwind's scanner never
72
+ sees a class to compile. */
73
+ .tr-map-marker {
74
+ display: grid;
75
+ place-items: center;
76
+ border-radius: var(--tr-radius-full);
77
+ background: var(--tr-color-primary);
78
+ }
79
+ ```
80
+
81
+ And the case where the fix is not a comment at all — this rule *is* expressible as
82
+ utilities, so the stylesheet goes away and the classes move onto the element:
83
+
84
+ ```tsx
85
+ <div className="grid place-items-center rounded-full bg-primary" />
86
+ ```
87
+
88
+ ## See also
89
+
90
+ - [Theme and CSS](../theme-and-css.md) — the token namespace utilities compile from, and
91
+ the layer order hand-CSS lands in.
92
+ - [`no-bare-css-import`](no-bare-css-import.md) — the other constraint on a hand-written
93
+ stylesheet: no `@import`, ever.
94
+ - [`unlayered-fence`](unlayered-fence.md) — the escape hatch *within* the escape hatch.
@@ -0,0 +1,112 @@
1
+ ---
2
+ purpose: The template-kit/determinism-drift 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/determinism-drift
8
+
9
+ > The same fixture, rendered twice, must produce **byte-identical** HTML. This is the
10
+ > runtime back-stop to `no-nondeterminism` — it catches what a linter cannot see.
11
+
12
+ ## Rule
13
+
14
+ Every fixture in the section's invariant corpus is server-rendered **twice**, and the two
15
+ outputs are compared byte for byte. Any difference is a violation.
16
+
17
+ The corpus is broader than the fixtures you wrote:
18
+
19
+ - your **`base`** fixture;
20
+ - each of your **`states`** fixtures;
21
+ - one **synthesized** fixture per **optional** slot, with that slot set to its empty value
22
+ (`null` for a scalar or an image, `[]` for a list).
23
+
24
+ You do not declare that last set. Nullability coverage is derived from the schema — from
25
+ `(type, required)` — so a nullable branch cannot be forgotten by forgetting to write a
26
+ fixture for it.
27
+
28
+ ## Reason
29
+
30
+ [`no-nondeterminism`](no-nondeterminism.md) bans the clock and randomness **in your code**.
31
+ A linter reads one file at a time and can only report on what it can see, so it stops at
32
+ your source. This check runs the render.
33
+
34
+ That gap is where the real bugs live, and they arrive **through a package**. A helper that
35
+ mints a fresh id per call. A library that iterates a hash and hands back a different order
36
+ each time. Nothing in your Renderer looks nondeterministic — you never wrote `Math.random()`
37
+ — but the output moves.
38
+
39
+ It matters because the publisher and the editor **both** render the section and must agree.
40
+ Byte-identical output is what makes a section's HTML content-addressable: the same content
41
+ must hash to the same page. A section whose HTML changes on every render re-publishes
42
+ forever and never converges.
43
+
44
+ ## Fix
45
+
46
+ If the nondeterminism is your own: compute the value at **fill time** and pass it in as a
47
+ slot value, or move the markup into a `"use client"` island — an island renders in the
48
+ browser after hydration, and its output is nobody's content hash.
49
+
50
+ If the nondeterminism came from a package: replace it, or give it a deterministic input.
51
+ An id generator gets a stable id derived from props. A collection gets sorted before you
52
+ map it.
53
+
54
+ ### Before
55
+
56
+ ```tsx
57
+ // sections/facts/Renderer.tsx
58
+ import { Section, Slot } from "@homepages/template-kit";
59
+ import { nanoid } from "nanoid";
60
+
61
+ import type { Props } from "./schema.js";
62
+
63
+ export function Renderer({ slots, nav }: Props) {
64
+ const descriptionId = nanoid(); // ← a new id on every render; the HTML differs each time
65
+
66
+ return (
67
+ <Section id={nav.selfAnchor}>
68
+ <Slot id="headline" as="h1" textLeaf>
69
+ <span aria-describedby={descriptionId}>{slots.headline}</span>
70
+ </Slot>
71
+ <p id={descriptionId}>{slots.summary}</p>
72
+ </Section>
73
+ );
74
+ }
75
+ ```
76
+
77
+ Nothing here reads the clock or a random source, so no lint rule fires. The section still
78
+ renders differently every single time.
79
+
80
+ ### After
81
+
82
+ The id is derived from something the section was **given**, so it is the same on every
83
+ render:
84
+
85
+ ```tsx
86
+ // sections/facts/Renderer.tsx
87
+ import { Section, Slot } from "@homepages/template-kit";
88
+
89
+ import type { Props } from "./schema.js";
90
+
91
+ export function Renderer({ slots, nav }: Props) {
92
+ const descriptionId = `${nav.selfAnchor}-summary`;
93
+
94
+ return (
95
+ <Section id={nav.selfAnchor}>
96
+ <Slot id="headline" as="h1" textLeaf>
97
+ <span aria-describedby={descriptionId}>{slots.headline}</span>
98
+ </Slot>
99
+ <p id={descriptionId}>{slots.summary}</p>
100
+ </Section>
101
+ );
102
+ }
103
+ ```
104
+
105
+ ## See also
106
+
107
+ - [`no-nondeterminism`](no-nondeterminism.md) — the same ban, enforced in your editor, on the
108
+ code a linter can see.
109
+ - [Server vs client](server-vs-client.md) — why a server-rendered file must be a pure
110
+ function of its props, and why an island is not.
111
+ - [`render-invariant`](render-invariant.md) — the other check that runs over this same
112
+ fixture corpus.
@@ -0,0 +1,96 @@
1
+ ---
2
+ purpose: The template-kit/fixtures-invalid authoring rule — what it enforces, why, and how to fix it.
3
+ status: living
4
+ related: [INDEX.md, ../schema-system.md, schema-invalid.md]
5
+ updated: 2026-07-14
6
+ ---
7
+ # template-kit/fixtures-invalid
8
+
9
+ > `fixtures.ts` is the one contract file nothing else parses for you. This is that parse.
10
+
11
+ ## Rule
12
+
13
+ The default export of `fixtures.ts` must satisfy the fixture-module shape:
14
+
15
+ ```ts
16
+ {
17
+ base: {
18
+ slots: { /* every required slot, with a valid value */ },
19
+ options: { /* optional */ },
20
+ anchors: { /* optional */ },
21
+ variants: { /* optional */ },
22
+ },
23
+ option_matrix: [ /* optional: { label?, options } entries */ ],
24
+ states: { /* optional: named scenario → { label?, slots?, options?, anchors?, variants? } */ },
25
+ }
26
+ ```
27
+
28
+ `base` is required and must be an object holding a `slots` record — every other key is
29
+ optional. Each violation names the exact path inside the module that's wrong (`"base"`,
30
+ `"base.slots"`, `"states.empty-address"`, …) and what shape was expected there instead.
31
+
32
+ ## Reason
33
+
34
+ `schema.ts` and `fill-spec.ts` are both Zod-parsed on the way into every other check, so a
35
+ malformed one is caught immediately. `fixtures.ts` used to be the exception: nothing
36
+ touched it until the render stage read it directly, so a wrong shape there surfaced as a
37
+ raw crash mid-render, blamed on whatever line happened to dereference the missing field —
38
+ never on the fixture that was actually wrong.
39
+
40
+ This gate exists to move that failure to where the mistake was made, with a message about
41
+ the fixture module instead of a stack trace from three files away.
42
+
43
+ ## Fix
44
+
45
+ Read the message's path — it points at the exact key that's wrong — and bring that key's
46
+ shape in line with what's documented above.
47
+
48
+ ### Before
49
+
50
+ ```ts
51
+ // sections/hero/fixtures.ts
52
+ import type { FixtureModule } from "@homepages/template-kit";
53
+
54
+ const fixtures: FixtureModule = {
55
+ base: {
56
+ slots: { headline: "A mansard Victorian in the heart of Jamaica Plain" },
57
+ },
58
+ states: {
59
+ "no-photo": { slots: { hero_image: null }, label: 12 },
60
+ // ^ label must be a string
61
+ },
62
+ };
63
+
64
+ export default fixtures;
65
+ ```
66
+
67
+ ```text
68
+ Section "hero": fixtures.ts — "states.no-photo.label": Expected string, received number.
69
+ ```
70
+
71
+ ### After
72
+
73
+ ```ts
74
+ // sections/hero/fixtures.ts
75
+ import type { FixtureModule } from "@homepages/template-kit";
76
+
77
+ const fixtures: FixtureModule = {
78
+ base: {
79
+ slots: { headline: "A mansard Victorian in the heart of Jamaica Plain" },
80
+ },
81
+ states: {
82
+ "no-photo": { slots: { hero_image: null }, label: "No hero photo" },
83
+ },
84
+ };
85
+
86
+ export default fixtures;
87
+ ```
88
+
89
+ ## See also
90
+
91
+ - [The schema system](../schema-system.md) — `fixtures.ts`'s place among the section's
92
+ four contract files.
93
+ - [`schema-invalid`](schema-invalid.md) — the sibling gate on `schema.ts` and
94
+ `fill-spec.ts`.
95
+ - [`bundle-incomplete`](bundle-incomplete.md) — reported instead of this id when
96
+ `fixtures.ts` is missing outright, rather than present and wrong.
@@ -0,0 +1,117 @@
1
+ ---
2
+ purpose: The template-kit/image-bare-needs-reason 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/image-bare-needs-reason
8
+
9
+ > `<Image bare>` needs a `// bare: <reason>` comment above it. **A reason cannot be spent
10
+ > twice** — one comment documents exactly one element.
11
+
12
+ ## Rule
13
+
14
+ Every `<Image bare …>` must have a qualifying reason comment. A comment qualifies when
15
+ all three hold:
16
+
17
+ 1. **It contains `bare:`** — e.g. `// bare: the logo is chrome; the parent already sizes it.`
18
+ 2. **It sits strictly above the element, within 5 lines.** Above, not beside and not below.
19
+ 3. **It has not already been spent** on an earlier `<Image bare>`.
20
+
21
+ Both comment forms count — a `//` line comment and a `{/* … */}` JSX comment. The rule
22
+ reads the raw comment stream rather than hopping up the AST, so proximity survives
23
+ arbitrary wrapping: a container `<div>`, a `.map()` callback, a parenthesized `return`, an
24
+ assignment to a `const`. If it *looks* directly above the element, it counts.
25
+
26
+ **The surprising part: a reason cannot be spent twice.** One comment above a row of
27
+ sibling bare logos documents only the **first** of them; the second still reports. Each
28
+ bare image is its own decision, so each gets its own sentence. (A comment written *between*
29
+ two bare images is fine for the second — it starts after the first element ends.)
30
+
31
+ ## Reason
32
+
33
+ Framed is `<Image>`'s default, and the frame is load-bearing: a wrapper `div` that holds
34
+ the aspect-ratio box, carries the slot marker, and renders a neutral fallback rect when
35
+ `src` is empty.
36
+
37
+ `bare` drops all of it — one `<img>`, no wrapper. So a bare image with no source is
38
+ **nothing at all**: the layout collapses, and whatever sat below it jumps up the page. A
39
+ missing image is a supported state on this platform (a thin listing simply has no photo),
40
+ so "the source is always there" is an assumption, not a fact.
41
+
42
+ `bare` is still the right call sometimes — a logo whose parent already establishes the
43
+ box, a slide inside its own aspect-ratio parent. The rule does not ban it; it makes you
44
+ **say why the parent guarantees the box**, so the next author (or the next agent) can
45
+ tell whether the frame is safe to restore.
46
+
47
+ ## Fix
48
+
49
+ Put a `// bare: <reason>` comment immediately above the element. A reason is legitimate in
50
+ either of two ways: it can name what **guarantees the layout box** without `<Image>`'s
51
+ frame (a parent that already establishes the size, a slide inside its own aspect-ratio
52
+ container) — or it can name something about the **image's own content** that makes the
53
+ frame the wrong choice, such as a transparent PNG that framing would letterbox. If
54
+ neither holds, drop `bare` and let `<Image>` frame it.
55
+
56
+ ### Before
57
+
58
+ ```tsx
59
+ import { Image, Section } from "@homepages/template-kit";
60
+
61
+ import type { Props } from "./schema.js";
62
+
63
+ export function Renderer({ slots }: Props) {
64
+ return (
65
+ <Section>
66
+ <div className="h-12 w-32">
67
+ <Image bare src={slots.brand_logo.url} alt={slots.brand_logo.alt} className="object-contain" />
68
+ </div>
69
+ </Section>
70
+ );
71
+ }
72
+ ```
73
+
74
+ ### After
75
+
76
+ ```tsx
77
+ import { Image, Section } from "@homepages/template-kit";
78
+
79
+ import type { Props } from "./schema.js";
80
+
81
+ export function Renderer({ slots }: Props) {
82
+ return (
83
+ <Section>
84
+ <div className="h-12 w-32">
85
+ {/* bare: the logo is chrome; the parent already sizes it. */}
86
+ <Image bare src={slots.brand_logo.url} alt={slots.brand_logo.alt} className="object-contain" />
87
+ </div>
88
+ </Section>
89
+ );
90
+ }
91
+ ```
92
+
93
+ Two bare images need two comments — the first one's reason is spent:
94
+
95
+ ```tsx
96
+ <div className="flex gap-4">
97
+ {/* bare: the primary logo is chrome; the row sizes it. */}
98
+ <Image bare src={slots.logo_a.url} alt={slots.logo_a.alt} className="h-8 object-contain" />
99
+ {/* bare: the partner logo is chrome; the row sizes it. */}
100
+ <Image bare src={slots.logo_b.url} alt={slots.logo_b.alt} className="h-8 object-contain" />
101
+ </div>
102
+ ```
103
+
104
+ A content-based reason is written the same way — only the sentence changes, because it's
105
+ justifying a different thing (the image, not the layout around it):
106
+
107
+ ```tsx
108
+ {/* bare: a transparent PNG badge; Image's frame would letterbox its transparent
109
+ padding against the section background. */}
110
+ <Image bare src={slots.certification_badge.url} alt={slots.certification_badge.alt} className="h-10" />
111
+ ```
112
+
113
+ ## See also
114
+
115
+ - [Contract primitives: `Image`](../primitives.md#image--the-defensive-image-primitive) —
116
+ framed vs bare, and what the frame does for you.
117
+ - [`no-raw-element`](no-raw-element.md) — why `<img>` goes through `<Image>` at all.