@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.
- package/CHANGELOG.md +220 -0
- package/README.md +70 -16
- package/dist/base.css +36 -48
- package/dist/cli/check/config.js +37 -0
- package/dist/cli/check/css.js +117 -0
- package/dist/cli/check/diagnostics.js +32 -0
- package/dist/cli/check/index.js +87 -0
- package/dist/cli/check/loader.js +142 -0
- package/dist/cli/check/paths.js +15 -0
- package/dist/cli/check/relativize.js +18 -0
- package/dist/cli/check/render-invariants.js +24 -0
- package/dist/cli/check/resolve-tool.js +38 -0
- package/dist/cli/check/stages/deps.js +337 -0
- package/dist/cli/check/stages/lint.js +46 -0
- package/dist/cli/check/stages/render.js +101 -0
- package/dist/cli/check/stages/size.js +158 -0
- package/dist/cli/check/stages/tree.js +207 -0
- package/dist/cli/check/stages/typecheck.js +46 -0
- package/dist/cli/check/stages/validate/catalog-exhaustiveness.js +18 -0
- package/dist/cli/check/stages/validate/facts.js +18 -0
- package/dist/cli/check/stages/validate/formatter-contract.js +14 -0
- package/dist/cli/check/stages/validate/group-contract.js +21 -0
- package/dist/cli/check/stages/validate/index.js +66 -0
- package/dist/cli/check/stages/validate/list-scalar-contract.js +22 -0
- package/dist/cli/check/stages/validate/nav-contract.js +93 -0
- package/dist/cli/check/stages/validate/nested-slot-contract.js +12 -0
- package/dist/cli/check/stages/validate/object-list-contract.js +31 -0
- package/dist/cli/check/stages/validate/orchestrator.js +241 -0
- package/dist/cli/check/stages/validate/produced-by-contract.js +18 -0
- package/dist/cli/check/stages/validate/row-contract.js +31 -0
- package/dist/cli/check/stages/validate/select-contract.js +13 -0
- package/dist/cli/check/stages/validate/source-contract.js +21 -0
- package/dist/cli/check/stages/validate/variant-contract.js +15 -0
- package/dist/cli/check/workspace.js +86 -0
- package/dist/cli/theme/generate.js +43 -0
- package/dist/cli/theme/index.js +33 -0
- package/dist/cli/theme/load-theme.js +69 -0
- package/dist/cli.js +22 -4
- package/dist/contracts/fill-treatments.js +47 -0
- package/dist/design-system/theme.d.ts +30 -299
- package/dist/design-system/theme.js +112 -83
- package/dist/eslint/rules/image-bare-needs-reason.js +6 -1
- package/dist/eslint/rules/no-client-directive-in-contract.js +6 -1
- package/dist/eslint/rules/no-client-runtime-in-server.js +5 -1
- package/dist/eslint/rules/no-css-import-from-render-path.js +8 -2
- package/dist/eslint/rules/no-hex.js +84 -7
- package/dist/eslint/rules/no-inline-style.js +5 -1
- package/dist/eslint/rules/no-nondeterminism.js +5 -1
- package/dist/eslint/rules/no-raw-element.js +6 -1
- package/dist/eslint/rules/props-from-schema.js +6 -1
- package/dist/eslint/rules/serializable-island-props.js +5 -1
- package/dist/eslint/rules/slot-marker-literal.js +6 -1
- package/dist/eslint.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/package.js +1 -1
- package/dist/primitives/Image.js +1 -1
- package/dist/rules/registry.js +16 -0
- package/dist/schema/fill-spec.d.ts +80 -590
- package/dist/schema/fixture-schema.d.ts +5 -81
- package/dist/schema/manifest.d.ts +39 -475
- package/dist/schema/resolve-section-ref.js +10 -0
- package/dist/schema/rows.js +10 -0
- package/dist/schema/section-nav.js +9 -0
- package/dist/schema/section-schema.d.ts +51 -437
- package/dist/schema/slot-types.d.ts +12 -16
- package/dist/styles.css +31 -88
- package/docs/INDEX.md +5 -3
- package/docs/check.md +121 -0
- package/docs/eslint.md +25 -50
- package/docs/llms.txt +26 -11
- package/docs/primitives.md +5 -0
- package/docs/rules/INDEX.md +65 -0
- package/docs/rules/audit-severity.md +91 -0
- package/docs/rules/bundle-binary-asset.md +102 -0
- package/docs/rules/bundle-incomplete.md +117 -0
- package/docs/rules/css-reason.md +94 -0
- package/docs/rules/determinism-drift.md +112 -0
- package/docs/rules/fixtures-invalid.md +96 -0
- package/docs/rules/image-bare-needs-reason.md +117 -0
- package/docs/rules/license-denied.md +114 -0
- package/docs/rules/lockfile-missing.md +68 -0
- package/docs/rules/lockfile-stale.md +88 -0
- package/docs/rules/manifest-invalid.md +99 -0
- package/docs/rules/missing-slot-marker.md +140 -0
- package/docs/rules/no-bare-css-import.md +128 -0
- package/docs/rules/no-client-directive-in-contract.md +111 -0
- package/docs/rules/no-client-runtime-in-server.md +143 -0
- package/docs/rules/no-css-import-from-render-path.md +118 -0
- package/docs/rules/no-hex.md +163 -0
- package/docs/rules/no-inline-style.md +121 -0
- package/docs/rules/no-nondeterminism.md +100 -0
- package/docs/rules/no-raw-element.md +100 -0
- package/docs/rules/no-templates.md +87 -0
- package/docs/rules/parse-error.md +76 -0
- package/docs/rules/props-from-schema.md +119 -0
- package/docs/rules/render-invariant.md +165 -0
- package/docs/rules/schema-invalid.md +96 -0
- package/docs/rules/serializable-island-props.md +146 -0
- package/docs/rules/server-vs-client.md +105 -0
- package/docs/rules/sidebar-order.md +109 -0
- package/docs/rules/single-react.md +97 -0
- package/docs/rules/size-renderer-bundle.md +109 -0
- package/docs/rules/size-section-css.md +95 -0
- package/docs/rules/slot-group-marker.md +127 -0
- package/docs/rules/slot-marker-literal.md +148 -0
- package/docs/rules/typecheck.md +103 -0
- package/docs/rules/unlayered-fence.md +113 -0
- package/docs/theme-and-css.md +193 -82
- package/package.json +4 -9
- package/tsconfig.json +1 -1
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
---
|
|
2
|
+
purpose: The template-kit/no-client-directive-in-contract authoring rule — what it enforces, why, and how to fix it.
|
|
3
|
+
status: living
|
|
4
|
+
related: [INDEX.md, server-vs-client.md, ../schema-system.md]
|
|
5
|
+
updated: 2026-07-14
|
|
6
|
+
---
|
|
7
|
+
# template-kit/no-client-directive-in-contract
|
|
8
|
+
|
|
9
|
+
> A section's four contract files may never carry `"use client"`. This is the one rule
|
|
10
|
+
> scoped by **filename**, not by the directive.
|
|
11
|
+
|
|
12
|
+
## Rule
|
|
13
|
+
|
|
14
|
+
The rule runs only on a section's four contract files — `Renderer.tsx`, `schema.ts`,
|
|
15
|
+
`fill-spec.ts`, `fixtures.ts` — matched by glob. On one of those files, a `"use client"`
|
|
16
|
+
string in the **directive prologue** is reported.
|
|
17
|
+
|
|
18
|
+
The prologue is the run of leading string-literal statements (see
|
|
19
|
+
[server vs client](server-vs-client.md)). Three consequences worth knowing:
|
|
20
|
+
|
|
21
|
+
- **`"use strict"; "use client";` reports.** A second directive is still in the prologue.
|
|
22
|
+
- **`const x = 1;` above `"use client"` does not report** — real code ended the prologue,
|
|
23
|
+
so that string is not a directive. This is a trap, not a loophole: the file is also
|
|
24
|
+
**not an island**, and every server-rendering rule still applies to it. It is a dead
|
|
25
|
+
string.
|
|
26
|
+
- **`foo("use client")` is not a directive** — it is a call argument, and is ignored.
|
|
27
|
+
|
|
28
|
+
Any other file in the section folder may carry the directive freely. That is exactly
|
|
29
|
+
where interactivity belongs.
|
|
30
|
+
|
|
31
|
+
## Reason
|
|
32
|
+
|
|
33
|
+
The four contract files are the platform's interface to a section. They are read by the
|
|
34
|
+
publisher, the editor, and the fill pipeline — **none of which is a browser**.
|
|
35
|
+
`schema.ts` is loaded to learn what slots exist; `fill-spec.ts` to learn how AI fills
|
|
36
|
+
them; `fixtures.ts` to render a preview; `Renderer.tsx` to produce the section's HTML.
|
|
37
|
+
|
|
38
|
+
Marking one `"use client"` would make the whole section an island — and take the
|
|
39
|
+
section's server-rendered HTML with it. The section would ship as an empty shell that
|
|
40
|
+
only appears once JavaScript has run, which is precisely what a published property page
|
|
41
|
+
must never do.
|
|
42
|
+
|
|
43
|
+
## Fix
|
|
44
|
+
|
|
45
|
+
Put the interactive part in its **own** `"use client"` component in the section folder,
|
|
46
|
+
and render it from the Renderer. The contract file stays server-only; the island next to
|
|
47
|
+
it does the browser work.
|
|
48
|
+
|
|
49
|
+
### Before
|
|
50
|
+
|
|
51
|
+
```tsx
|
|
52
|
+
// sections/gallery/Renderer.tsx
|
|
53
|
+
"use client";
|
|
54
|
+
|
|
55
|
+
import { useState } from "react";
|
|
56
|
+
|
|
57
|
+
import { Section } from "@homepages/template-kit";
|
|
58
|
+
|
|
59
|
+
import type { Props } from "./schema.js";
|
|
60
|
+
|
|
61
|
+
export function Renderer({ slots }: Props) {
|
|
62
|
+
const [open, setOpen] = useState(false);
|
|
63
|
+
return (
|
|
64
|
+
<Section>
|
|
65
|
+
<button onClick={() => setOpen(true)}>Open gallery</button>
|
|
66
|
+
{open && <p>{slots.headline}</p>}
|
|
67
|
+
</Section>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### After
|
|
73
|
+
|
|
74
|
+
```tsx
|
|
75
|
+
// sections/gallery/Lightbox.tsx
|
|
76
|
+
"use client";
|
|
77
|
+
|
|
78
|
+
import { useState } from "react";
|
|
79
|
+
|
|
80
|
+
export default function Lightbox({ headline }: { headline: string }) {
|
|
81
|
+
const [open, setOpen] = useState(false);
|
|
82
|
+
return (
|
|
83
|
+
<div>
|
|
84
|
+
<button onClick={() => setOpen(true)}>Open gallery</button>
|
|
85
|
+
{open && <p>{headline}</p>}
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
```tsx
|
|
92
|
+
// sections/gallery/Renderer.tsx — no directive; server-rendered again
|
|
93
|
+
import { Section } from "@homepages/template-kit";
|
|
94
|
+
|
|
95
|
+
import Lightbox from "./Lightbox.js";
|
|
96
|
+
import type { Props } from "./schema.js";
|
|
97
|
+
|
|
98
|
+
export function Renderer({ slots }: Props) {
|
|
99
|
+
return (
|
|
100
|
+
<Section>
|
|
101
|
+
<Lightbox headline={slots.headline} />
|
|
102
|
+
</Section>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## See also
|
|
108
|
+
|
|
109
|
+
- [Server vs client](server-vs-client.md) — the directive, the prologue, and what an
|
|
110
|
+
island may do.
|
|
111
|
+
- [Schema system](../schema-system.md) — what each of the four contract files is for.
|
|
@@ -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,163 @@
|
|
|
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-15
|
|
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
|
+
This rule ships as a **warning**, not an error: a section spending theme tokens is
|
|
15
|
+
recommended, not gated. A hard-coded colour squiggles in your editor, but it does not
|
|
16
|
+
fail `template-kit check` (the check gates on errors only, so a warning never reaches it)
|
|
17
|
+
and so does not block ingest either. The guidance below is still the right thing to do —
|
|
18
|
+
it only changes what the linter does with a violation, not whether the violation is
|
|
19
|
+
worth fixing.
|
|
20
|
+
|
|
21
|
+
The rule flags two shapes:
|
|
22
|
+
|
|
23
|
+
- **A CSS colour function that bakes a colour**, anywhere in a string or template
|
|
24
|
+
literal: `oklch(…)`, `oklab(…)`, `rgb(…)`, `rgba(…)`, `hsl(…)`, `hsla(…)`,
|
|
25
|
+
`color-mix(…)`. No context test — nobody spells an anchor id `rgb(...)`. **One
|
|
26
|
+
carve-out:** a `color-mix()` whose colour arguments are all theme-derived is legal —
|
|
27
|
+
see below.
|
|
28
|
+
- **A hex literal** (3, 4, 6, or 8 digits) **in a colour context**. A colour context is
|
|
29
|
+
a Tailwind arbitrary-value bracket that resolves to a colour:
|
|
30
|
+
- a colour property — `[color:#fff]`, `[background:#fff]`, `[background-color:#fff]`,
|
|
31
|
+
and the rest of `border-color`, `outline-color`, `box-shadow`, `text-shadow`,
|
|
32
|
+
`fill`, `stroke`, `caret-color`, `accent-color`, `text-decoration-color`;
|
|
33
|
+
- **any** custom property — `[--brand:#ff0000]`;
|
|
34
|
+
- a colour-utility prefix, with variants stripped — `bg-[#f00]`, `hover:bg-[#f00]`,
|
|
35
|
+
`md:dark:text-[#f00]`, `border-t-[#000]`, and the same for `ring`, `outline`,
|
|
36
|
+
`shadow`, `fill`, `stroke`, `decoration`, `divide`, `accent`, `caret`,
|
|
37
|
+
`placeholder`, `from`, `via`, `to`;
|
|
38
|
+
- or anywhere inside a JSX `style` attribute.
|
|
39
|
+
|
|
40
|
+
Everywhere else, a `#`-prefixed string is left alone: `href="#cafe"` is an anchor, and
|
|
41
|
+
`w-[#…]` is not a colour utility. `#cafe`, `#facade`, `#decade`, and `#beef` are real
|
|
42
|
+
anchor ids that happen to be spelled from hex digits.
|
|
43
|
+
|
|
44
|
+
Every hex-shaped token in a string is checked, not just the first — an out-of-context
|
|
45
|
+
token early in a `className` cannot shadow a genuine colour later in it.
|
|
46
|
+
|
|
47
|
+
### The `color-mix()` carve-out
|
|
48
|
+
|
|
49
|
+
A `color-mix()` carries **no palette of its own** when every one of its colour arguments
|
|
50
|
+
is theme-derived — a `var(--…)` reference, `transparent`, `currentColor`, or a nested
|
|
51
|
+
`color-mix()` judged on these same terms. It composes a colour out of the theme the
|
|
52
|
+
section was handed, so it is as theme-agnostic as `bg-surface` and cannot break when
|
|
53
|
+
another template reuses the section. It is legal:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
hover:bg-[color-mix(in_srgb,var(--tr-color-surface)_92%,var(--tr-color-ink))]
|
|
57
|
+
bg-[color-mix(in_srgb,var(--tr-color-ink)_50%,transparent)] ← a translucent tint:
|
|
58
|
+
overlay, scrim, hover wash
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
A mix weight (`92%`) is not a colour; only the colour arguments are tested.
|
|
62
|
+
|
|
63
|
+
`transparent` and `currentColor` are the only palette-free keywords: `transparent` names
|
|
64
|
+
the *absence* of a colour, and `currentColor` defers to the inherited (theme-driven)
|
|
65
|
+
`color`. Every other CSS named colour (`white`, `black`, `rebeccapurple`, …) is a baked
|
|
66
|
+
palette exactly as a hex is. So these report:
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
color-mix(in srgb, var(--tr-color-primary) 80%, #fff) ← a hex literal
|
|
70
|
+
color-mix(in srgb, var(--tr-color-primary) 80%, white) ← a CSS named colour
|
|
71
|
+
color-mix(in srgb, var(--tr-color-accent, #fff) 80%, …) ← a var FALLBACK is a baked
|
|
72
|
+
colour the moment the
|
|
73
|
+
property is undefined
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The carve-out is `color-mix()` **only** — it is the one colour function that *composes* a
|
|
77
|
+
colour rather than stating one. The other six take raw channel values, so they bake a
|
|
78
|
+
colour by construction: `rgb(0 0 0)`, `oklch(0.7 0.1 20)`, and even
|
|
79
|
+
`rgb(var(--r) var(--g) var(--b))` report.
|
|
80
|
+
|
|
81
|
+
## Reason
|
|
82
|
+
|
|
83
|
+
A section carries **zero palette of its own**. It is theme-agnostic, and must look
|
|
84
|
+
right under every template's theme — so a baked colour breaks the first time another
|
|
85
|
+
template reuses the section. Keeping colour in tokens is what makes a section portable
|
|
86
|
+
rather than a one-off.
|
|
87
|
+
|
|
88
|
+
This is why islands get no exemption. An island is exempt from the effect, state,
|
|
89
|
+
handler, and inline-`style` bans because those are server-render concerns and an island
|
|
90
|
+
runs in the browser. A palette is not a render-path concern: the hydration boundary is
|
|
91
|
+
not a palette boundary.
|
|
92
|
+
|
|
93
|
+
## Fix
|
|
94
|
+
|
|
95
|
+
Use one of your theme's colour utilities. If the value is genuinely brand decoration with
|
|
96
|
+
no token yet, add it to `colors` in the **template's** `theme.ts` — the key `lagoon`
|
|
97
|
+
compiles to `--tr-color-lagoon` and the `bg-lagoon` / `text-lagoon` utilities — which
|
|
98
|
+
keeps the value in one place instead of in one section.
|
|
99
|
+
|
|
100
|
+
If you need a colour the theme does not name — a hover, active, or pressed step — derive
|
|
101
|
+
it from the tokens the theme *does* name with `color-mix()`, rather than reaching for a
|
|
102
|
+
literal to lighten or darken with.
|
|
103
|
+
|
|
104
|
+
### Before
|
|
105
|
+
|
|
106
|
+
```tsx
|
|
107
|
+
import { Section } from "@homepages/template-kit";
|
|
108
|
+
|
|
109
|
+
import type { Props } from "./schema.js";
|
|
110
|
+
|
|
111
|
+
export function Renderer({ slots }: Props) {
|
|
112
|
+
return (
|
|
113
|
+
<Section>
|
|
114
|
+
<div className="bg-[#f5f5f5]">
|
|
115
|
+
<h2 style={{ color: "#1a1a1a" }}>{slots.headline}</h2>
|
|
116
|
+
</div>
|
|
117
|
+
</Section>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### After
|
|
123
|
+
|
|
124
|
+
```tsx
|
|
125
|
+
import { Section } from "@homepages/template-kit";
|
|
126
|
+
|
|
127
|
+
import type { Props } from "./schema.js";
|
|
128
|
+
|
|
129
|
+
export function Renderer({ slots }: Props) {
|
|
130
|
+
return (
|
|
131
|
+
<Section>
|
|
132
|
+
<div className="bg-surface-alt">
|
|
133
|
+
<h2 className="text-ink">{slots.headline}</h2>
|
|
134
|
+
</div>
|
|
135
|
+
</Section>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### A state colour, derived
|
|
141
|
+
|
|
142
|
+
Lightening a hover step by mixing in a hard-coded white is both the violation and the
|
|
143
|
+
bug: under a dark theme it lightens a button the theme wanted dark. Mix toward a colour
|
|
144
|
+
the **theme** names, and the step follows whatever theme the section is handed.
|
|
145
|
+
|
|
146
|
+
```tsx
|
|
147
|
+
// Before — `#fff` is a palette this section invented.
|
|
148
|
+
<button className="bg-primary hover:bg-[color-mix(in_srgb,var(--tr-color-primary)_80%,#fff)]">
|
|
149
|
+
{slots.cta}
|
|
150
|
+
</button>
|
|
151
|
+
|
|
152
|
+
// After — every colour argument is a theme var.
|
|
153
|
+
<button className="bg-primary hover:bg-[color-mix(in_srgb,var(--tr-color-primary)_80%,var(--tr-color-surface))]">
|
|
154
|
+
{slots.cta}
|
|
155
|
+
</button>
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## See also
|
|
159
|
+
|
|
160
|
+
- [Theme and CSS](../theme-and-css.md#tokentheme--your-templates-design-system) — the
|
|
161
|
+
token system, and how a `colors` entry becomes a utility.
|
|
162
|
+
- [Server vs client](server-vs-client.md) — why the other bans stop at the hydration
|
|
163
|
+
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.
|