@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,74 @@
1
1
  # @homepages/template-kit
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - e64d4d7: `base.css` no longer ships a reset — Tailwind's preflight (from your entry's
8
+ `@import "tailwindcss"`) already is one, and the two were duplicating each other. It
9
+ now carries only the three declarations preflight does not make: font-smoothing,
10
+ `button { cursor: pointer }`, and `svg { max-width: 100% }`.
11
+
12
+ **`base.css` now requires a Tailwind entry on the page.** If a page loads `base.css`
13
+ without an `@import "tailwindcss"` entry present — a standalone preview, an embed, a
14
+ hand-written CSS page — it renders fully unreset (UA body margin, bulleted lists,
15
+ underlined links, unconstrained inline images), and nothing errors to tell you.
16
+ Confirm every page that loads `base.css` also loads a Tailwind entry.
17
+
18
+ `base.css` also no longer references any theme token. The `body` document defaults
19
+ (font, color, background, size) are now emitted by `compileThemeToCss`, alongside the
20
+ `:root` block that defines those tokens. **Regenerate and republish your compiled theme
21
+ CSS.** If you upgrade `base.css` but keep serving a previously-compiled theme
22
+ stylesheet, that stylesheet still only has the old `:root` block — `body` silently
23
+ loses `font-family`, `color`, `background`, and `font-size` page-wide, because
24
+ `base.css` no longer carries them as a fallback. The compiled output's content hash
25
+ changes.
26
+
27
+ The `--tr-space-*` scale is removed — Tailwind's own spacing scale (`p-4`, `gap-6`)
28
+ was never aliased to it. Any of your CSS still referencing `var(--tr-space-*)` (e.g.
29
+ `padding: var(--tr-space-4)`) now points at an undefined custom property, which is
30
+ invalid at computed-value time, so the declaration drops. The `scroll-behavior` /
31
+ `scroll-padding-top` policy is removed; set it in your own template stylesheet if you
32
+ have a sticky header.
33
+
34
+ - a24174f: Every rule-id now resolves to a page. The lint preset and `template-kit check` share one
35
+ id namespace (`template-kit/<id>`), and each of those ids — all 28 — now has a page at
36
+ `docs/rules/<id>.md` giving the rule, the reason it exists, and a before/after fix you can
37
+ copy.
38
+
39
+ The docs ship **inside the tarball**, so the loop closes with no network: hit
40
+ `template-kit/no-hex`, read `node_modules/@homepages/template-kit/docs/rules/no-hex.md`,
41
+ fix it. That matters most for the coding agents authoring against this kit — an agent that
42
+ can resolve a failure to its own reference page fixes the violation instead of guessing at
43
+ it.
44
+
45
+ Each ESLint rule also carries `meta.docs.url` pointing at its page, so an editor's "show
46
+ documentation for this rule" lands on the same file.
47
+
48
+ Start at `docs/rules/INDEX.md`. `docs/rules/server-vs-client.md` explains the one concept
49
+ several rules assume — that server-vs-client is a **per-file** fact, decided by the
50
+ `"use client"` directive prologue, not by an import graph.
51
+
52
+ ### Patch Changes
53
+
54
+ - be1507d: Fixed `no-css-import-from-render-path`, which banned the one legal way to get a
55
+ third-party stylesheet into a section.
56
+
57
+ A **bare** `.css` specifier in the render path — `import "swiper/css"` in `Renderer.tsx`,
58
+ in a marked enhancer, or in any module either one imports — is how a package's CSS reaches
59
+ the section's cascade layer: the build bundles that import graph precisely to harvest it.
60
+ The rule flagged it anyway, and its `Fix:` hint sent you to `@import` the stylesheet from
61
+ `styles.css`, which is never legal there (hand-written CSS is wrapped in `@layer`, where a
62
+ browser drops an `@import` silently).
63
+
64
+ The rule now reports only a **local** stylesheet import — a relative or absolute specifier.
65
+ That is the real defect: the build already collects every `.css` file in the section folder
66
+ by filename, so importing one from code ships its rules twice and can corrupt the cascade.
67
+
68
+ If you worked around the old rule by moving a package's CSS into a stylesheet `@import`,
69
+ move it back into the module that needs it — see `docs/rules/no-css-import-from-render-path.md`
70
+ and `docs/rules/no-bare-css-import.md`, which are the two halves of the same mental model.
71
+
3
72
  ## 0.1.0
4
73
 
5
74
  ### Minor Changes
package/README.md CHANGED
@@ -65,7 +65,7 @@ Full contract: **[docs/primitives.md](docs/primitives.md)**.
65
65
  | `@homepages/template-kit/island-runtime` | Browser loader that hydrates a page's islands |
66
66
  | `@homepages/template-kit/browser` | Browser helpers for published pages (site config, signed ingest) |
67
67
  | `@homepages/template-kit/styles.css` | Tailwind v4 theme tokens, the breakpoint ladder, kit source registration |
68
- | `@homepages/template-kit/base.css` | The page's base stylesheet: layer order, reset, `.tr-section`, universal tokens |
68
+ | `@homepages/template-kit/base.css` | Cascade-layer order, the three reset gaps Tailwind's preflight leaves, `.tr-section`, and the motion tokens |
69
69
  | `@homepages/template-kit/eslint` | ESLint flat-config preset |
70
70
  | `@homepages/template-kit/tsconfig` | tsconfig preset (`"extends"`) |
71
71
 
@@ -80,9 +80,19 @@ write a section against. The two CSS entries are the deliberate exception.
80
80
  ```
81
81
 
82
82
  Two `@import "tailwindcss"` in one graph double-emits preflight. `base.css` is not part
83
- of that entry — it loads as its own stylesheet on the page. Full contract:
83
+ of that entry — it loads as its own stylesheet on the page. `base.css` ships no reset
84
+ of its own — it depends on the preflight your entry's `@import "tailwindcss"` brings.
85
+ Both must be on the page. Full contract:
84
86
  **[docs/theme-and-css.md](docs/theme-and-css.md)**.
85
87
 
88
+ ## Rules
89
+
90
+ The lint preset and the `template-kit check` CLI share one id namespace,
91
+ `template-kit/<id>`. A rule-id printed by either resolves to a page —
92
+ `docs/rules/<id>.md` — giving the rule, the reason it exists, and a before/after fix.
93
+ Start at **[docs/rules/INDEX.md](docs/rules/INDEX.md)**; the full doc set is routed
94
+ from **[docs/INDEX.md](docs/INDEX.md)**.
95
+
86
96
  ## Development
87
97
 
88
98
  ```bash
package/dist/base.css CHANGED
@@ -7,52 +7,46 @@
7
7
  *
8
8
  * Contains:
9
9
  * - The cascade-layer order declaration above — the one place it is stated
10
- * - The @layer base reset
11
- * - Document defaults (html/body)
10
+ * - The three reset gaps Tailwind's preflight leaves (below)
12
11
  * - The section box model (.tr-section, emitted by the <Section> primitive)
13
- * - Universal tokens: the spacing scale and motion
12
+ * - Universal tokens: motion
14
13
  *
15
- * NOT here: anything a template owns. Colors, fonts, type, radii, shadows and
16
- * layout all come from your theme.ts, compiled by compileThemeToCss into a
17
- * `:root { --tr-*: }` block. Component styling is Tailwind utilities written
18
- * on the elements themselves.
14
+ * REQUIRES A TAILWIND ENTRY ON THE PAGE. Your entry's `@import "tailwindcss"` brings
15
+ * Tailwind's preflight, and preflight does the reset: box-sizing, margin/padding,
16
+ * list-style, the button and anchor resets, display:block on replaced elements. This
17
+ * file only fills the three gaps preflight leaves. Ship base.css without a Tailwind
18
+ * entry and you get an unreset page.
19
+ *
20
+ * NOT here: anything a template owns. Colors, fonts, type, radii, shadows and layout
21
+ * — and the `body` document defaults that apply them — all come from your theme.ts,
22
+ * compiled by compileThemeToCss. This file references no theme token at all.
19
23
  */
20
24
 
21
- /* ----- Reset (lightweight; assumes modern browsers) --------------------- *
25
+ /* ----- Reset gaps ------------------------------------------------------- *
26
+ * The three declarations Tailwind's preflight does NOT make. Each is load-bearing:
27
+ * delete one and your page changes.
28
+ *
22
29
  * Wrapped in `@layer base` so Tailwind's utilities (which compile into
23
- * `@layer utilities`) win over these element defaults. Unlayered, a rule like
24
- * `a { color: inherit }` would beat the layered `.text-ink` utility outright —
25
- * unlayered always wins over layered in the cascade.
30
+ * `@layer utilities`) win over these element defaults. Unlayered, `button { cursor:
31
+ * pointer }` would beat a layered `.cursor-default` utility outright — unlayered
32
+ * always wins over layered in the cascade.
26
33
  */
27
34
 
28
35
  @layer base {
29
- *, *::before, *::after { box-sizing: border-box; }
30
- html, body { margin: 0; padding: 0; }
36
+ /* preflight carries no font-smoothing rule at all */
31
37
  body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
32
- img, svg, video { display: block; max-width: 100%; }
33
- button { background: none; border: 0; padding: 0; cursor: pointer; font: inherit; color: inherit; }
34
- a { color: inherit; text-decoration: none; }
35
- ul, ol { margin: 0; padding: 0; list-style: none; }
36
- h1, h2, h3, h4, h5, h6, p { margin: 0; }
37
- }
38
-
39
- /* ----- Document defaults ------------------------------------------------ */
40
-
41
- html { scroll-behavior: smooth; scroll-padding-top: var(--tr-header-height); }
42
- body {
43
- font-family: var(--tr-font-sans);
44
- color: var(--tr-color-ink);
45
- background: var(--tr-color-background);
46
- font-size: var(--tr-text-base);
47
- line-height: 1.5;
38
+ /* Tailwind v4 deliberately leaves buttons at the UA default `cursor: default` */
39
+ button { cursor: pointer; }
40
+ /* preflight caps max-width on img and video, but not on svg */
41
+ svg { max-width: 100%; }
48
42
  }
49
43
 
50
44
  /* ----- Primitives ------------------------------------------------------- */
51
45
 
52
- /* The full-bleed section root, emitted by the <Section> primitive. Always 100%
53
- * wide with zero padding/margin, so adjacent sections butt edge-to-edge and a page
54
- * composes as a vertical stack with no framework-injected whitespace. A section
55
- * brings its own background and internal padding. */
46
+ /* The full-bleed section root, emitted by the <Section> primitive. Always 100% wide
47
+ * with zero padding/margin, so adjacent sections butt edge-to-edge and a page composes
48
+ * as a vertical stack with no framework-injected whitespace. A section brings its own
49
+ * background and internal padding. */
56
50
  .tr-section {
57
51
  display: block;
58
52
  width: 100%;
@@ -63,21 +57,6 @@ body {
63
57
  /* ----- Universal tokens ------------------------------------------------- */
64
58
 
65
59
  :root {
66
- /* Spacing scale */
67
- --tr-space-1: 0.25rem;
68
- --tr-space-2: 0.5rem;
69
- --tr-space-3: 0.75rem;
70
- --tr-space-4: 1rem;
71
- --tr-space-5: 1.25rem;
72
- --tr-space-6: 1.5rem;
73
- --tr-space-8: 2rem;
74
- --tr-space-10: 2.5rem;
75
- --tr-space-12: 3rem;
76
- --tr-space-16: 4rem;
77
- --tr-space-20: 5rem;
78
- --tr-space-24: 6rem;
79
- --tr-space-32: 8rem;
80
-
81
60
  /* Motion */
82
61
  --tr-duration-fast: 150ms;
83
62
  --tr-duration-base: 250ms;
@@ -298,7 +298,8 @@ declare const TokenThemeSchema: z.ZodObject<{
298
298
  type TokenTheme = z.infer<typeof TokenThemeSchema>;
299
299
  /**
300
300
  * Compile a `TokenTheme` into the CSS a template's bundle ships: an optional
301
- * font-loading block followed by a single `:root { --tr-*: … }` declaration.
301
+ * font-loading block, a `:root { --tr-*: … }` declaration that defines the
302
+ * design tokens, and a `body` block that applies those tokens to the document.
302
303
  * Deterministic (stable key order; palette keys sorted) so the same theme
303
304
  * always content-hashes identically.
304
305
  */
@@ -72,9 +72,16 @@ fontFaces: z.string().optional() }),
72
72
  shadows: scale(Object.keys(SHADOW_VARS)),
73
73
  layout: scale(Object.keys(LAYOUT_VARS))
74
74
  }).strict();
75
+ const BODY_DEFAULTS = `body {
76
+ font-family: var(--tr-font-sans);
77
+ color: var(--tr-color-ink);
78
+ background: var(--tr-color-background);
79
+ font-size: var(--tr-text-base);
80
+ }`;
75
81
  /**
76
82
  * Compile a `TokenTheme` into the CSS a template's bundle ships: an optional
77
- * font-loading block followed by a single `:root { --tr-*: … }` declaration.
83
+ * font-loading block, a `:root { --tr-*: … }` declaration that defines the
84
+ * design tokens, and a `body` block that applies those tokens to the document.
78
85
  * Deterministic (stable key order; palette keys sorted) so the same theme
79
86
  * always content-hashes identically.
80
87
  */
@@ -87,8 +94,8 @@ function compileThemeToCss(theme) {
87
94
  for (const k of Object.keys(RADIUS_VARS)) decls.push(` ${RADIUS_VARS[k]}: ${theme.radii[k]};`);
88
95
  for (const k of Object.keys(SHADOW_VARS)) decls.push(` ${SHADOW_VARS[k]}: ${theme.shadows[k]};`);
89
96
  for (const k of Object.keys(LAYOUT_VARS)) decls.push(` ${LAYOUT_VARS[k]}: ${theme.layout[k]};`);
90
- const root = `:root {\n${decls.join("\n")}\n}`;
91
- return theme.fonts.fontFaces ? `${theme.fonts.fontFaces}\n\n${root}\n` : `${root}\n`;
97
+ const blocks = `${`:root {\n${decls.join("\n")}\n}`}\n\n${BODY_DEFAULTS}`;
98
+ return theme.fonts.fontFaces ? `${theme.fonts.fontFaces}\n\n${blocks}\n` : `${blocks}\n`;
92
99
  }
93
100
 
94
101
  //#endregion
@@ -1,8 +1,13 @@
1
+ import { docsPathForRule } from "../../rules/registry.js";
2
+
1
3
  //#region src/eslint/rules/image-bare-needs-reason.ts
2
4
  const imageBareNeedsReason = {
3
5
  meta: {
4
6
  type: "problem",
5
- docs: { description: "<Image bare> requires a `// bare:` reason comment" },
7
+ docs: {
8
+ description: "<Image bare> requires a `// bare:` reason comment",
9
+ url: docsPathForRule("image-bare-needs-reason")
10
+ },
6
11
  schema: [],
7
12
  messages: { bareNeedsReason: "`<Image bare>` skips the framed wrapper's aspect-ratio box and fallback rect. Fix: document why this case warrants it with a `// bare: <reason>` comment immediately above the element." }
8
13
  },
@@ -1,8 +1,13 @@
1
+ import { docsPathForRule } from "../../rules/registry.js";
2
+
1
3
  //#region src/eslint/rules/no-client-directive-in-contract.ts
2
4
  const noClientDirectiveInContract = {
3
5
  meta: {
4
6
  type: "problem",
5
- docs: { description: "Ban the \"use client\" directive in a section's four contract files" },
7
+ docs: {
8
+ description: "Ban the \"use client\" directive in a section's four contract files",
9
+ url: docsPathForRule("no-client-directive-in-contract")
10
+ },
6
11
  schema: [],
7
12
  messages: { clientContractFile: "A section's contract files (Renderer.tsx, schema.ts, fill-spec.ts, fixtures.ts) are server-only and must not carry \"use client\" — the platform reads them outside a browser. Fix: put the interactive part in its own \"use client\" component in the section folder and render it from the Renderer." }
8
13
  },
@@ -1,3 +1,4 @@
1
+ import { docsPathForRule } from "../../rules/registry.js";
1
2
  import { hasClientDirective, isClientFile, resolveImportedFile } from "../is-client-file.js";
2
3
 
3
4
  //#region src/eslint/rules/no-client-runtime-in-server.ts
@@ -22,7 +23,10 @@ const BANNED_GLOBALS = /* @__PURE__ */ new Set([
22
23
  const noClientRuntimeInServer = {
23
24
  meta: {
24
25
  type: "problem",
25
- docs: { description: "Ban effects, state, handlers, network, and browser globals in server-rendered section code" },
26
+ docs: {
27
+ description: "Ban effects, state, handlers, network, and browser globals in server-rendered section code",
28
+ url: docsPathForRule("no-client-runtime-in-server")
29
+ },
26
30
  schema: [],
27
31
  messages: {
28
32
  clientRuntime: "`{{what}}` is browser-only and is banned in server-rendered code — a Renderer must be a pure function of its props. Fix: move this into a `\"use client\"` component in the section folder and render it from the Renderer; it will be hydrated as an island.",
@@ -1,15 +1,21 @@
1
+ import { docsPathForRule } from "../../rules/registry.js";
2
+
1
3
  //#region src/eslint/rules/no-css-import-from-render-path.ts
2
4
  const noCssImportFromRenderPath = {
3
5
  meta: {
4
6
  type: "problem",
5
- docs: { description: "Ban CSS imports from a section's render path" },
7
+ docs: {
8
+ description: "Ban local CSS imports from a section's render path",
9
+ url: docsPathForRule("no-css-import-from-render-path")
10
+ },
6
11
  schema: [],
7
- messages: { cssFromRenderPath: "`{{source}}` is a CSS import in the render path — the renderer is compiled for SSR, where this is dropped, so the styles would silently not ship. Fix: `@import` it from the section's root `styles.css`, which is the entry the CSS build actually reads." }
12
+ messages: { cssFromRenderPath: "`{{source}}` is a local stylesheet imported from the render path — the build already collects every `.css` file in the section folder by filename, so this ships the rules twice. Fix: delete the import; the file is picked up on its own. (A package's stylesheet belongs here, imported by its bare specifier — that is how it reaches the section's layer.)" }
8
13
  },
9
14
  create(context) {
10
15
  return { ImportDeclaration(node) {
11
16
  const source = node.source.value;
12
17
  if (typeof source !== "string" || !source.endsWith(".css")) return;
18
+ if (!source.startsWith(".") && !source.startsWith("/")) return;
13
19
  context.report({
14
20
  node,
15
21
  messageId: "cssFromRenderPath",
@@ -1,3 +1,5 @@
1
+ import { docsPathForRule } from "../../rules/registry.js";
2
+
1
3
  //#region src/eslint/rules/no-hex.ts
2
4
  const COLOR_FN = /(?:oklch|oklab|rgba?|hsla?|color-mix)\s*\([^)]*\)/;
3
5
  const HEX = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})\b/g;
@@ -67,7 +69,10 @@ function isWithinStyleAttribute(node) {
67
69
  const noHex = {
68
70
  meta: {
69
71
  type: "problem",
70
- docs: { description: "Ban hard-coded colour literals in section code" },
72
+ docs: {
73
+ description: "Ban hard-coded colour literals in section code",
74
+ url: docsPathForRule("no-hex")
75
+ },
71
76
  schema: [],
72
77
  messages: { hardCodedColor: "`{{value}}` is a hard-coded colour — a section carries no palette of its own, or it breaks the first time another template reuses it. Fix: use a semantic token utility (e.g. `bg-surface-alt`, `text-ink`); if the value is genuinely brand decoration with no token, add it to the template's `theme.ts` palette and use the `--tr-color-<name>` utility it compiles to." }
73
78
  },
@@ -1,10 +1,14 @@
1
+ import { docsPathForRule } from "../../rules/registry.js";
1
2
  import { hasClientDirective } from "../is-client-file.js";
2
3
 
3
4
  //#region src/eslint/rules/no-inline-style.ts
4
5
  const noInlineStyle = {
5
6
  meta: {
6
7
  type: "problem",
7
- docs: { description: "Ban the inline style prop in server-rendered section JSX" },
8
+ docs: {
9
+ description: "Ban the inline style prop in server-rendered section JSX",
10
+ url: docsPathForRule("no-inline-style")
11
+ },
8
12
  schema: [],
9
13
  messages: { inlineStyle: "The inline `style=` prop is banned in server-rendered section JSX — it bypasses the cascade and Tailwind's responsive prefixes. Fix: use Tailwind utility classes; for a value that only exists at runtime, move the markup into a `\"use client\"` island, where `style` is allowed." }
10
14
  },
@@ -1,3 +1,4 @@
1
+ import { docsPathForRule } from "../../rules/registry.js";
1
2
  import { hasClientDirective } from "../is-client-file.js";
2
3
 
3
4
  //#region src/eslint/rules/no-nondeterminism.ts
@@ -10,7 +11,10 @@ const BANNED_MEMBERS = {
10
11
  const noNondeterminism = {
11
12
  meta: {
12
13
  type: "problem",
13
- docs: { description: "Ban clock and randomness reads in server-rendered section code" },
14
+ docs: {
15
+ description: "Ban clock and randomness reads in server-rendered section code",
16
+ url: docsPathForRule("no-nondeterminism")
17
+ },
14
18
  schema: [],
15
19
  messages: { nondeterministic: "`{{what}}` is nondeterministic and is banned in server-rendered code — the publisher and the editor must render byte-identical HTML. Fix: compute this at fill time and pass it in as a slot value, or move the code into a `\"use client\"` island, where it runs in the browser." }
16
20
  },
@@ -1,3 +1,5 @@
1
+ import { docsPathForRule } from "../../rules/registry.js";
2
+
1
3
  //#region src/eslint/rules/no-raw-element.ts
2
4
  const FORBIDDEN = {
3
5
  section: "<Section>",
@@ -6,7 +8,10 @@ const FORBIDDEN = {
6
8
  const noRawElement = {
7
9
  meta: {
8
10
  type: "problem",
9
- docs: { description: "Ban raw <section> and <img> in section JSX" },
11
+ docs: {
12
+ description: "Ban raw <section> and <img> in section JSX",
13
+ url: docsPathForRule("no-raw-element")
14
+ },
10
15
  schema: [],
11
16
  messages: { rawElement: "Raw `<{{tag}}>` is banned in section JSX — {{use}} carries the contract it skips. Fix: `import { {{name}} } from \"@homepages/template-kit\";` and use {{use}}." }
12
17
  },
@@ -1,8 +1,13 @@
1
+ import { docsPathForRule } from "../../rules/registry.js";
2
+
1
3
  //#region src/eslint/rules/props-from-schema.ts
2
4
  const propsFromSchema = {
3
5
  meta: {
4
6
  type: "problem",
5
- docs: { description: "A Renderer must import its Props type from ./schema" },
7
+ docs: {
8
+ description: "A Renderer must import its Props type from ./schema",
9
+ url: docsPathForRule("props-from-schema")
10
+ },
6
11
  schema: [],
7
12
  messages: { handWritten: "`{{name}}` is a hand-written props type in a Renderer — it bypasses the schema→Props inference and will drift. Fix: add `export type Props = SectionProps<typeof schema>;` to schema.ts and `import type { Props } from \"./schema.js\";` here." }
8
13
  },
@@ -1,3 +1,4 @@
1
+ import { docsPathForRule } from "../../rules/registry.js";
1
2
  import { isClientFile, resolveImportedFile } from "../is-client-file.js";
2
3
 
3
4
  //#region src/eslint/rules/serializable-island-props.ts
@@ -56,7 +57,10 @@ function inspect(node, path, out) {
56
57
  const serializableIslandProps = {
57
58
  meta: {
58
59
  type: "problem",
59
- docs: { description: "Island props must be JSON-serializable" },
60
+ docs: {
61
+ description: "Island props must be JSON-serializable",
62
+ url: docsPathForRule("serializable-island-props")
63
+ },
60
64
  schema: [],
61
65
  messages: {
62
66
  notSerializableBadType: "{{path}} is {{what}} — island props must be JSON-serializable (string, number, boolean, null, plain object, array). Fix: pass data, not behavior — an island owns its own handlers.",
@@ -1,3 +1,5 @@
1
+ import { docsPathForRule } from "../../rules/registry.js";
2
+
1
3
  //#region src/eslint/rules/slot-marker-literal.ts
2
4
  const KEY_ATTRS = /* @__PURE__ */ new Set([
3
5
  "data-slot-id",
@@ -8,7 +10,10 @@ const KEY_COMPONENTS = /* @__PURE__ */ new Set(["Slot", "SlotGroup"]);
8
10
  const slotMarkerLiteral = {
9
11
  meta: {
10
12
  type: "problem",
11
- docs: { description: "Slot markers must carry a string-literal key" },
13
+ docs: {
14
+ description: "Slot markers must carry a string-literal key",
15
+ url: docsPathForRule("slot-marker-literal")
16
+ },
12
17
  schema: [],
13
18
  messages: { markerNotLiteral: "`{{attr}}` must be a string literal, not an expression — the slot-marker completeness check reads this key statically, and cannot resolve a computed one (the slot would silently lose editor selection). Fix: write the key inline, e.g. `{{attr}}=\"headline\"`." }
14
19
  },
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.1.0";
2
+ var version = "0.2.0";
3
3
 
4
4
  //#endregion
5
5
  export { version };
@@ -0,0 +1,16 @@
1
+ //#region src/rules/registry.ts
2
+ /**
3
+ * `no-hex` → `docs/rules/no-hex.md`.
4
+ *
5
+ * Package-relative on purpose: the caller decides what to resolve it against. A
6
+ * printer prefixes the resolved package root so an author sees a path they can open
7
+ * with no network (`node_modules/@homepages/template-kit/docs/rules/no-hex.md`);
8
+ * ESLint's `meta.docs.url` uses it as-is. This module never touches the filesystem —
9
+ * the ESLint preset imports it.
10
+ */
11
+ function docsPathForRule(id) {
12
+ return `docs/rules/${id}.md`;
13
+ }
14
+
15
+ //#endregion
16
+ export { docsPathForRule };
package/dist/styles.css CHANGED
@@ -93,7 +93,7 @@
93
93
  --text-5xl: var(--tr-text-5xl);
94
94
  --text-display: var(--tr-text-display);
95
95
 
96
- /* ---------- Spacing scale (alias to --tr-space-*) ---------- */
96
+ /* ---------- Spacing (Tailwind's own scale no custom aliases) ---------- */
97
97
  --spacing: 0.25rem; /* v4 base unit; Tailwind multiplies (e.g. p-4 = 1rem) */
98
98
 
99
99
  /* ---------- Radii ---------- */
package/docs/INDEX.md CHANGED
@@ -18,4 +18,5 @@ detail.
18
18
  | `primitives.md` | The five contract primitives (`Section`, `Slot`, `SlotItem`, `SlotGroup`, `Image`) and the marker attributes they emit | writing a section's markup |
19
19
  | `theme-and-css.md` | The two CSS entries, the one-Tailwind-import rule, `TokenTheme`, the breakpoint ladder, and the opt-in `content-visibility` recipe for long, image-heavy pages | a template's theme, its CSS entry, or a long page with a live map or full-screen overlay |
20
20
  | `islands.md` | Interactivity: `"use client"` components, the serializable-props rule, editor options, the loader | anything interactive in a section |
21
- | `eslint.md` | The `@homepages/template-kit/eslint` preset: zero-config setup, its glob scope, and the eleven `template-kit/*` rule ids | wiring up lint in a template workspace, or adding/changing an authoring rule |
21
+ | `eslint.md` | The `@homepages/template-kit/eslint` preset: zero-config setup and its glob scope | wiring up lint in a template workspace |
22
+ | `rules/` | One page per rule-id (`template-kit/*`), across both venues — the id printed by a lint or `check` failure resolves here. `rules/INDEX.md` routes them; `rules/server-vs-client.md` is the concept the server-rendering rules assume | any lint or `check` failure |
package/docs/eslint.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- purpose: The authoring-lint preset shipped at `@homepages/template-kit/eslint` — setup, scope, and the eleven rule ids.
2
+ purpose: The authoring-lint preset shipped at `@homepages/template-kit/eslint` — setup and scope. Per-rule reference lives in `rules/`.
3
3
  status: living
4
- related: [INDEX.md, islands.md, theme-and-css.md, primitives.md]
4
+ related: [INDEX.md, rules/INDEX.md, islands.md, theme-and-css.md, primitives.md]
5
5
  updated: 2026-07-14
6
6
  ---
7
7
  # ESLint preset
@@ -36,55 +36,20 @@ three config entries, matched by glob rather than by directory depth:
36
36
 
37
37
  | Entry | Files | Rules |
38
38
  |---|---|---|
39
- | `template-kit/authoring` | `**/*.ts`, `**/*.tsx` | the nine file-scoped rules below |
39
+ | `template-kit/authoring` | `**/*.ts`, `**/*.tsx` | the nine file-scoped rules see [`rules/`](rules/INDEX.md) |
40
40
  | `template-kit/contract-files` | `**/Renderer.tsx`, `**/schema.ts`, `**/fill-spec.ts`, `**/fixtures.ts` | `no-client-directive-in-contract` |
41
41
  | `template-kit/renderer` | `**/Renderer.tsx` | `props-from-schema` |
42
42
 
43
43
  Server-vs-client scoping is decided **per file**, by the `"use client"` directive
44
- prologue — not by an import graph or a build transform. A file carrying the
45
- directive is an island: it hydrates in the browser, so effects, state, event
46
- handlers, and inline `style` are legal there and the corresponding rules exempt
47
- it. A rule that does not exempt islands says so explicitly below.
44
+ prologue — see [server vs client](rules/server-vs-client.md) for what that means
45
+ and exactly which rules stop at that boundary.
48
46
 
49
47
  ## The rules
50
48
 
51
- Rules that are properties of one file live here. Rules that are properties of the
52
- **tree** a missing contract file, a slot declared in `schema.ts` but unmarked in
53
- `Renderer.tsx`, a CSS rule cannot be expressed one-file-at-a-time; they belong to
54
- `template-kit check` (not yet built) under the same `template-kit/<id>` namespace.
55
- One namespace, two venues.
56
-
57
- ### Server-rendered code
58
-
59
- | Rule | What it bans |
60
- |---|---|
61
- | `no-nondeterminism` | Clock/randomness reads (`Date.now()`, `new Date()` with no args, `Math.random()`, `crypto.randomUUID()`/`getRandomValues()`, `performance.now()`) — the publisher and the editor must render byte-identical HTML. Exempt inside an island. |
62
- | `no-client-runtime-in-server` | Effects, state hooks, event handlers, and browser globals (`fetch`, `window`, `document`, …) — a Renderer must be a pure function of its props. Exempt inside an island. |
63
- | `no-inline-style` | The `style=` JSX prop — it bypasses the cascade and Tailwind's responsive prefixes. Exempt inside an island. |
64
- | `no-raw-element` | Raw `<section>`/`<img>` — each has a contract-bearing primitive (`Section`/`Image`) that must be used instead. |
65
- | `image-bare-needs-reason` | `<Image bare>` with no `// bare: <reason>` comment justifying the skipped frame. |
66
- | `no-css-import-from-render-path` | A `.css` import anywhere in a section's render path — the SSR graph drops it silently. Import CSS from the section's own `styles.css` instead. |
67
-
68
- ### Islands
69
-
70
- | Rule | What it checks |
71
- |---|---|
72
- | `serializable-island-props` | A statically-visible prop passed to an island (a literal function, `new Date()`/`Map`/`Set`/etc., a bigint literal, `NaN`/`Infinity`) that cannot cross the server→browser JSON boundary. It is the static twin of the runtime check — see [islands.md](islands.md#props-must-be-json-serializable). |
73
-
74
- ### Universal — no island exemption
75
-
76
- | Rule | What it bans |
77
- |---|---|
78
- | `no-hex` | Hex color literals and CSS color functions (`oklch`/`oklab`/`rgb`/`rgba`/`hsl`/`hsla`/`color-mix`) in a color context (a Tailwind color-utility bracket, an arbitrary-property color declaration, or an inline `style` value). Token discipline is universal — see [theme-and-css.md](theme-and-css.md#color-roles). |
79
-
80
- Deliberate asymmetry: `no-inline-style` exempts islands, `no-hex` does not — the
81
- hydration boundary is not a palette boundary. A section carries no palette of its
82
- own in either render path.
83
-
84
- ### Markers and schema
85
-
86
- | Rule | What it checks |
87
- |---|---|
88
- | `slot-marker-literal` | A slot-key-bearing marker (`<Slot id>`, `<SlotGroup id>`, `slotId`, `data-slot-id`, `data-slot-group`) must carry a string-literal key, not an expression — the slot-marker completeness check (`template-kit check`) reads these keys statically. `SlotItem` is exempt: its prop is `index: number`, an array position, so there is no schema key to cross-check. |
89
- | `props-from-schema` | A `Renderer.tsx`'s exported `Props` type must come from `SectionProps<typeof schema>`. A hand-written `Props` shadows that inference and drifts. |
90
- | `no-client-directive-in-contract` | `"use client"` in a section's four contract files (`Renderer.tsx`, `schema.ts`, `fill-spec.ts`, `fixtures.ts`) — the platform reads them outside a browser; marking one an island takes the section's server-rendered HTML with it. |
49
+ Every rule the preset enforces what it bans, why, and a before/after fix — is
50
+ documented once, per id, in [`rules/`](rules/INDEX.md). Rules scoped to a single
51
+ file are enforced here, by this preset; rules scoped to the **tree** — a missing
52
+ contract file, a slot declared in `schema.ts` but unmarked in `Renderer.tsx`, a CSS
53
+ budget cannot be expressed one-file-at-a-time and belong to `template-kit check`
54
+ under the same `template-kit/<id>` namespace. One namespace, two venues: the id
55
+ printed by either failure resolves to exactly one page.
package/docs/llms.txt CHANGED
@@ -27,7 +27,8 @@ Everything an author writes a section against is imported from the package root
27
27
  and `Image` (responsive `<picture>`, empty-state box). Plus the marker-attribute
28
28
  contract (`ATTR_SLOT_ID`, `ATTR_SLOT_ITEM`, …). Presentation is the template's own JSX.
29
29
  - [Theme and CSS](theme-and-css.md): the two CSS entries and how they load; import
30
- Tailwind exactly once; `TokenTheme` + `compileThemeToCss`; the breakpoint ladder
30
+ Tailwind exactly once; `base.css` ships no reset of its own — it requires the
31
+ preflight your Tailwind entry brings; `TokenTheme` + `compileThemeToCss`; the breakpoint ladder
31
32
  (base 350, `bp390` `bp425` `bp512` `md` `lg` `bp1440` `bp1920`); the opt-in
32
33
  `content-visibility` recipe for long, image-heavy pages, and why it requires a
33
34
  full-screen overlay to be appended to `document.body` rather than rendered inside
@@ -35,6 +36,8 @@ Everything an author writes a section against is imported from the package root
35
36
  - [README](../README.md): install, exports, and the release flow.
36
37
  - [Islands](islands.md): interactivity — client components, the serializable-props contract, editor options, and the browser loader.
37
38
  - [ESLint preset](eslint.md): `@homepages/template-kit/eslint`, the zero-config flat-config
38
- preset covering the eleven `template-kit/*` authoring rules determinism, the
39
- server/island boundary, serializable island props, the marker and schema contracts,
40
- and token discipline.
39
+ preset setup and its three-entry glob scope. Per-rule reference lives in `rules/`.
40
+ - [Rules](rules/INDEX.md): every rule-id a lint or `check` failure can print, under the
41
+ `template-kit/<id>` namespace, one page each at `docs/rules/<id>.md` — Rule / Reason /
42
+ Fix / Before→After. **The loop:** see an id in a failure → read `docs/rules/<id>.md` →
43
+ fix. Nothing else to search for; the id is the filename.