@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,58 @@
1
+ ---
2
+ purpose: Router for the rule corpus — every template-kit/<id> an author can hit, and the page that explains it.
3
+ status: living
4
+ related: [server-vs-client.md, ../eslint.md, ../INDEX.md]
5
+ updated: 2026-07-14
6
+ ---
7
+ # Rules
8
+
9
+ Every authoring rule the kit enforces is printed under one namespace, `template-kit/<id>`,
10
+ and resolves to exactly one page here: `docs/rules/<id>.md`.
11
+
12
+ **One namespace, two venues.** Some rules are properties of **one file** (a `Date.now()`
13
+ in a Renderer) and are enforced by the ESLint preset. Others are properties of the
14
+ **tree** (a slot declared in `schema.ts` but never marked in the JSX, a stylesheet, a
15
+ dependency, a byte budget) and cannot be expressed one file at a time; those are enforced
16
+ by `template-kit check`. You never need to know which engine produced a violation — only
17
+ the id, which names its page either way.
18
+
19
+ Read [server vs client](server-vs-client.md) first if you are here for any rule that
20
+ mentions "server-rendered code" or an island. It is the one concept those rules assume,
21
+ and it is explained there rather than on each of them.
22
+
23
+ | Rule | Venue | What it enforces |
24
+ |---|---|---|
25
+ | [`no-nondeterminism`](no-nondeterminism.md) | eslint | No clock or randomness reads in server-rendered code. |
26
+ | [`no-client-runtime-in-server`](no-client-runtime-in-server.md) | eslint | No effects, state, event handlers, network, or browser globals in server-rendered code. |
27
+ | [`serializable-island-props`](serializable-island-props.md) | eslint | Props handed to an island must be JSON-serializable. |
28
+ | [`no-client-directive-in-contract`](no-client-directive-in-contract.md) | eslint | No "use client" in a section's four contract files. |
29
+ | [`props-from-schema`](props-from-schema.md) | eslint | A Renderer's Props type must be derived from schema.ts, not hand-written. |
30
+ | [`no-inline-style`](no-inline-style.md) | eslint | No inline style= in server-rendered JSX. Islands are exempt. |
31
+ | [`no-raw-element`](no-raw-element.md) | eslint | No raw `<section>` or `<img>` — use `<Section>` and `<Image>`. |
32
+ | [`image-bare-needs-reason`](image-bare-needs-reason.md) | eslint | `<Image bare>` needs a `// bare:` comment saying why it skips the frame. |
33
+ | [`slot-marker-literal`](slot-marker-literal.md) | eslint | A slot-marker key must be a string literal, not an expression. |
34
+ | [`no-hex`](no-hex.md) | eslint | No hard-coded colours. Islands are NOT exempt. |
35
+ | [`no-css-import-from-render-path`](no-css-import-from-render-path.md) | eslint | No relative/absolute .css import from a section's render path — a package's is the sanctioned channel. |
36
+ | [`bundle-incomplete`](bundle-incomplete.md) | check | A section folder is missing one of its four required files. |
37
+ | [`missing-slot-marker`](missing-slot-marker.md) | check | An editable slot in schema.ts has no marker in the section's JSX. |
38
+ | [`slot-group-marker`](slot-group-marker.md) | check | A group with two or more members has no `<SlotGroup>` wrapper. |
39
+ | [`css-reason`](css-reason.md) | check | A hand-written CSS file must open with a `css-reason:` comment. |
40
+ | [`no-bare-css-import`](no-bare-css-import.md) | check | No @import in hand-written CSS — the browser drops it silently. |
41
+ | [`unlayered-fence`](unlayered-fence.md) | check | An `/* unlayered: */` escape hatch must be balanced and give a reason. |
42
+ | [`sidebar-order`](sidebar-order.md) | check | Schema slot order must match the order the page renders them in. |
43
+ | [`typecheck`](typecheck.md) | check | The template must compile under TypeScript. |
44
+ | [`determinism-drift`](determinism-drift.md) | check | Rendering the same fixture twice must produce byte-identical HTML. |
45
+ | [`render-invariant`](render-invariant.md) | check | Rendered output must carry no [object Object], bare null/undefined/NaN leaf, or src-less `<img>`. |
46
+ | [`lockfile-missing`](lockfile-missing.md) | check | The workspace has no package-lock.json. |
47
+ | [`lockfile-stale`](lockfile-stale.md) | check | The lockfile does not satisfy package.json — an `npm ci` would fail. |
48
+ | [`single-react`](single-react.md) | check | The installed tree carries more than one copy of react or react-dom. |
49
+ | [`audit-severity`](audit-severity.md) | check | A dependency carries a high or critical security advisory. |
50
+ | [`license-denied`](license-denied.md) | check | A production dependency's license is not on the allowlist. |
51
+ | [`size-renderer-bundle`](size-renderer-bundle.md) | check | A section's renderer bundle exceeds its byte budget. |
52
+ | [`size-section-css`](size-section-css.md) | check | A section's compiled CSS exceeds its byte budget. |
53
+
54
+ ## See also
55
+
56
+ - [Server vs client](server-vs-client.md) — how a file is scoped, and which rules stop at
57
+ the hydration boundary.
58
+ - [ESLint preset](../eslint.md) — setting the preset up in a template workspace.
@@ -0,0 +1,91 @@
1
+ ---
2
+ purpose: The template-kit/audit-severity authoring rule — what it enforces, why, and how to fix it.
3
+ status: living
4
+ related: [INDEX.md, license-denied.md]
5
+ updated: 2026-07-14
6
+ ---
7
+ # template-kit/audit-severity
8
+
9
+ > A dependency with a **high** or **critical** advisory fails. Low and moderate pass, on
10
+ > purpose. There is no way to suppress it.
11
+
12
+ ## Rule
13
+
14
+ `npm audit` runs over your workspace, and a violation is reported for each advisory at
15
+ severity **high** or **critical**.
16
+
17
+ **Low and moderate advisories are a deliberate pass.** They are not "not yet enforced" —
18
+ they are not enforced. Nobody will ask you to clear them.
19
+
20
+ **There is no suppression mechanism.** No inline waiver, no ignore file, no severity
21
+ override. If you are looking for the flag, there isn't one, and that is the design.
22
+
23
+ ## Reason
24
+
25
+ The kit lets you reach for third-party packages — a carousel, a map SDK, a date formatter —
26
+ because a section that has to hand-write everything is a section nobody finishes. The
27
+ consequence is that your dependency tree ships to a published page we host for a customer,
28
+ so the supply chain is part of the submission.
29
+
30
+ This gate is the **floor**, not a review. Cutting at high/critical is what keeps it a floor:
31
+ a rule that fired on every transitive moderate would be a rule everyone learned to route
32
+ around, and the exception would swallow the check. Two severities, no exceptions, and a
33
+ clear answer to "can I ship this."
34
+
35
+ ## Fix
36
+
37
+ In order of preference:
38
+
39
+ 1. **`npm audit fix`** — resolves most advisories by moving a transitive dependency inside
40
+ its existing range.
41
+ 2. **Upgrade the offending package.** If the fix is in a new major, `npm audit fix --force`
42
+ will take it, but read what it changes first.
43
+ 3. **Drop the package.** An advisory with no fix available is a package with no fix
44
+ available. Replace it, or hand-write the part of it you actually use.
45
+
46
+ Then re-run `npm audit` and commit the lockfile.
47
+
48
+ ### Before
49
+
50
+ ```text
51
+ $ npm audit
52
+ # npm audit report
53
+
54
+ marked <4.0.10
55
+ Severity: high
56
+ Inefficient Regular Expression Complexity in marked
57
+ fix available via `npm audit fix --force`
58
+
59
+ 1 high severity vulnerability
60
+ ```
61
+
62
+ ```jsonc
63
+ // package.json
64
+ {
65
+ "dependencies": {
66
+ "marked": "^3.0.8"
67
+ }
68
+ }
69
+ ```
70
+
71
+ ### After
72
+
73
+ ```jsonc
74
+ // package.json
75
+ {
76
+ "dependencies": {
77
+ "marked": "^14.1.2"
78
+ }
79
+ }
80
+ ```
81
+
82
+ ```text
83
+ $ npm audit
84
+ found 0 vulnerabilities
85
+ ```
86
+
87
+ ## See also
88
+
89
+ - [`license-denied`](license-denied.md) — the other supply-chain gate on your production
90
+ dependencies.
91
+ - [`lockfile-stale`](lockfile-stale.md) — commit the lockfile the fix regenerated.
@@ -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,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.