@homepages/template-kit 0.0.0 → 0.1.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 +182 -0
- package/README.md +71 -14
- package/dist/base.css +85 -0
- package/dist/browser.d.ts +26 -0
- package/dist/browser.js +55 -0
- package/dist/cli.js +1 -1
- package/dist/contracts/image-descriptor.d.ts +17 -0
- package/dist/contracts/markers.d.ts +20 -0
- package/dist/contracts/markers.js +21 -0
- package/dist/contracts/slot-catalog.d.ts +237 -0
- package/dist/contracts/slot-catalog.js +247 -0
- package/dist/contracts/transforms.d.ts +108 -0
- package/dist/contracts/transforms.js +117 -0
- package/dist/design-system/breakpoints.d.ts +5 -0
- package/dist/design-system/breakpoints.js +14 -0
- package/dist/design-system/theme.d.ts +307 -0
- package/dist/design-system/theme.js +95 -0
- package/dist/eslint/is-client-file.js +79 -0
- package/dist/eslint/rules/image-bare-needs-reason.js +38 -0
- package/dist/eslint/rules/no-client-directive-in-contract.js +26 -0
- package/dist/eslint/rules/no-client-runtime-in-server.js +99 -0
- package/dist/eslint/rules/no-css-import-from-render-path.js +23 -0
- package/dist/eslint/rules/no-hex.js +111 -0
- package/dist/eslint/rules/no-inline-style.js +23 -0
- package/dist/eslint/rules/no-nondeterminism.js +44 -0
- package/dist/eslint/rules/no-raw-element.js +32 -0
- package/dist/eslint/rules/props-from-schema.js +32 -0
- package/dist/eslint/rules/serializable-island-props.js +108 -0
- package/dist/eslint/rules/slot-marker-literal.js +33 -0
- package/dist/eslint.d.ts +4 -8
- package/dist/eslint.js +75 -11
- package/dist/fixtures/sample-property.d.ts +67 -0
- package/dist/fixtures/sample-property.js +707 -0
- package/dist/fixtures.d.ts +2 -0
- package/dist/fixtures.js +3 -0
- package/dist/index.d.ts +37 -13
- package/dist/index.js +34 -2
- package/dist/island-runtime.d.ts +30 -0
- package/dist/island-runtime.js +73 -0
- package/dist/islands/contract.d.ts +56 -0
- package/dist/islands/contract.js +71 -0
- package/dist/islands/marker.d.ts +16 -0
- package/dist/islands/marker.js +18 -0
- package/dist/package.js +5 -0
- package/dist/primitives/Image.d.ts +49 -0
- package/dist/primitives/Image.js +95 -0
- package/dist/primitives/Section.d.ts +22 -0
- package/dist/primitives/Section.js +26 -0
- package/dist/primitives/Slot.d.ts +50 -0
- package/dist/primitives/Slot.js +39 -0
- package/dist/primitives/SlotGroup.d.ts +38 -0
- package/dist/primitives/SlotGroup.js +24 -0
- package/dist/primitives/SlotItem.d.ts +43 -0
- package/dist/primitives/SlotItem.js +38 -0
- package/dist/primitives/index.d.ts +7 -0
- package/dist/primitives/picture-sources.d.ts +22 -0
- package/dist/primitives/picture-sources.js +52 -0
- package/dist/schema/fill-spec.d.ts +692 -0
- package/dist/schema/fill-spec.js +98 -0
- package/dist/schema/fixture-schema.d.ts +143 -0
- package/dist/schema/fixture-schema.js +113 -0
- package/dist/schema/index.d.ts +16 -0
- package/dist/schema/manifest.d.ts +527 -0
- package/dist/schema/manifest.js +73 -0
- package/dist/schema/property-facts.d.ts +44 -0
- package/dist/schema/resolve-variants.d.ts +11 -0
- package/dist/schema/resolve-variants.js +15 -0
- package/dist/schema/runtime-values.d.ts +45 -0
- package/dist/schema/section-nav.d.ts +7 -0
- package/dist/schema/section-props.d.ts +69 -0
- package/dist/schema/section-schema.d.ts +533 -0
- package/dist/schema/section-schema.js +72 -0
- package/dist/schema/slot-types.d.ts +117 -0
- package/dist/schema/slot-types.js +181 -0
- package/dist/schema/source.d.ts +13 -0
- package/dist/schema/source.js +12 -0
- package/dist/schema/synthesize-nullable.d.ts +18 -0
- package/dist/schema/synthesize-nullable.js +47 -0
- package/dist/styles.css +131 -9
- package/docs/INDEX.md +6 -4
- package/docs/eslint.md +90 -0
- package/docs/islands.md +150 -0
- package/docs/llms.txt +30 -3
- package/docs/primitives.md +214 -0
- package/docs/schema-system.md +240 -0
- package/docs/theme-and-css.md +179 -0
- package/package.json +30 -6
- package/tsconfig.base.json +16 -0
- package/tsconfig.json +2 -13
- package/dist/src-CrdHXijV.js +0 -23
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
---
|
|
2
|
+
purpose: The section authoring contract — schema.ts, fill-spec.ts, fixtures.ts, and the closed source/transform vocabulary.
|
|
3
|
+
status: living
|
|
4
|
+
related: [primitives.md, theme-and-css.md]
|
|
5
|
+
updated: 2026-07-14
|
|
6
|
+
---
|
|
7
|
+
# The schema system
|
|
8
|
+
|
|
9
|
+
Everything here is imported from the package root:
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { direct, type SectionProps, type SectionSchemaShape } from "@homepages/template-kit";
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
There are no subpath imports for the authoring API — one specifier, on purpose.
|
|
16
|
+
|
|
17
|
+
A section is **four files in one folder**. Three of them are declarations the
|
|
18
|
+
platform reads; the fourth is your React.
|
|
19
|
+
|
|
20
|
+
| File | Declares | Key type |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| `schema.ts` | The slots and options the section has | `SectionSchemaShape` |
|
|
23
|
+
| `fill-spec.ts` | How AI fills those slots | `FillSpecShape` |
|
|
24
|
+
| `fixtures.ts` | Sample content for local preview | `FixtureModule` |
|
|
25
|
+
| `Renderer.tsx` | The markup | `SectionProps<typeof schema>` |
|
|
26
|
+
|
|
27
|
+
## `schema.ts` — what the section contains
|
|
28
|
+
|
|
29
|
+
Declare the schema `as const satisfies SectionSchemaShape`. **The `as const` is
|
|
30
|
+
load-bearing**: it preserves the literal types that everything downstream infers
|
|
31
|
+
from. Without it, `SectionProps` cannot tell a required slot from an optional one
|
|
32
|
+
and your renderer props collapse to a useless widened shape.
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { direct, type FrameConfig, type SectionProps, type SectionSchemaShape } from "@homepages/template-kit";
|
|
36
|
+
|
|
37
|
+
export const HERO_FRAME = {
|
|
38
|
+
breakpoint: 425,
|
|
39
|
+
desktop: { aspect: 5 / 3 },
|
|
40
|
+
} as const satisfies FrameConfig;
|
|
41
|
+
|
|
42
|
+
export const schema = {
|
|
43
|
+
meta: { displayName: "Hero" },
|
|
44
|
+
slots: {
|
|
45
|
+
headline: { type: "text", size: "medium", required: true, produced_by: "headline_text" },
|
|
46
|
+
listing_status: { type: "select", required: false, source: direct("listing_intent") },
|
|
47
|
+
hero_image: { type: "image", required: true, produced_by: "hero_image_assign", frame: HERO_FRAME },
|
|
48
|
+
},
|
|
49
|
+
options: {
|
|
50
|
+
align: { type: "enum", values: ["left", "center"], default: "left" },
|
|
51
|
+
},
|
|
52
|
+
} as const satisfies SectionSchemaShape;
|
|
53
|
+
|
|
54
|
+
export type Props = SectionProps<typeof schema>;
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
A **slot** is content: something a user can edit or AI can fill. An **option** is
|
|
58
|
+
behavior: a switch the section reads to render differently. If a user would type
|
|
59
|
+
into it, it is a slot.
|
|
60
|
+
|
|
61
|
+
### Binding a slot to a property fact
|
|
62
|
+
|
|
63
|
+
`direct(field)` binds a slot to a known fact about the property; `derived(name)`
|
|
64
|
+
binds it to a computed transform. Both take names from a **closed vocabulary**, so
|
|
65
|
+
a typo is a compile error at the call site — not a runtime surprise:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
source: direct("listing_intent") // ✅
|
|
69
|
+
source: direct("listing_intentt") // ✗ compile error: not a SOURCE_FIELDS key
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The vocabulary is the kit's, not yours. Read it off the exported tables —
|
|
73
|
+
`SOURCE_FIELDS` (facts `direct()` accepts), `TRANSFORMS` (`derived()`), and
|
|
74
|
+
`SLOT_TYPE_DESCRIPTORS` (the slot types) — or let your editor autocomplete it.
|
|
75
|
+
Adding to it is a kit release, not a change you can make in a template.
|
|
76
|
+
|
|
77
|
+
### Grouping slots into one editor card
|
|
78
|
+
|
|
79
|
+
`meta.groups` collapses several slots into a single card in the editor sidebar. Each
|
|
80
|
+
group declares an `id`, a `label`, and its `members` — a bare slot id is a full-width
|
|
81
|
+
row, a `[a, b]` tuple is one 2-up row:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
meta: {
|
|
85
|
+
displayName: "Contact",
|
|
86
|
+
groups: [
|
|
87
|
+
{ id: "agent", label: "Agent", members: ["agent_name", ["agent_email", "agent_phone"]] },
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The renderer must match this by wrapping the members in `<SlotGroup id="agent">` — see
|
|
93
|
+
[Contract primitives](primitives.md#slotgroup--slots-edited-as-one-unit).
|
|
94
|
+
|
|
95
|
+
## `Renderer.tsx` — props derived, never hand-written
|
|
96
|
+
|
|
97
|
+
Never hand-write your prop type. Derive it, and the schema stays the single
|
|
98
|
+
source of truth:
|
|
99
|
+
|
|
100
|
+
```tsx
|
|
101
|
+
import { Section, Slot } from "@homepages/template-kit";
|
|
102
|
+
|
|
103
|
+
import type { Props } from "./schema.js";
|
|
104
|
+
|
|
105
|
+
export default function Hero({ slots, options, nav }: Props) {
|
|
106
|
+
// headline is `string` — the schema says required: true
|
|
107
|
+
// listing_status is `string | null` — required: false
|
|
108
|
+
// options.align is `"left" | "center"` — the enum's declared values
|
|
109
|
+
return (
|
|
110
|
+
<Section id={nav.selfAnchor}>
|
|
111
|
+
<Slot id="headline" as="h1" textLeaf>
|
|
112
|
+
<span>{slots.headline}</span>
|
|
113
|
+
</Slot>
|
|
114
|
+
</Section>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`SectionProps` reads the schema literal and resolves each slot to its runtime
|
|
120
|
+
value shape: a required `text` becomes `string`, an optional one `string | null`,
|
|
121
|
+
an `image` becomes `ImageValue`, an `image_collection` an array, and so on. A slot
|
|
122
|
+
you did not declare is a compile error to read.
|
|
123
|
+
|
|
124
|
+
An `ImageValue` splits into fields a renderer **reads** and fields it only **carries**.
|
|
125
|
+
Read `url`, `alt`, `responsive`, and `mobile`, and pass them to
|
|
126
|
+
[`Image`](primitives.md#image--the-defensive-image-primitive). Ignore `rect`, `ar`, and
|
|
127
|
+
`crop_key`: they are the editor's re-edit state (so its cropper can reopen at the last
|
|
128
|
+
crop) and the provenance of the already-cropped `url`. **Framing is baked into the asset
|
|
129
|
+
the platform serves, never applied at render time** — an uncropped image is a centered
|
|
130
|
+
`object-cover` over the original and a cropped one is a pre-cropped derivative, so there
|
|
131
|
+
is no framing for a renderer to apply. A slot's `crop` config steers the editor's cropper,
|
|
132
|
+
not your markup.
|
|
133
|
+
|
|
134
|
+
The markup a section is *required* to emit — the section root, the editor's slot
|
|
135
|
+
markers, images — comes from the kit's five primitives; everything else is your own
|
|
136
|
+
JSX. See [Contract primitives](primitives.md).
|
|
137
|
+
|
|
138
|
+
Renderers must be **pure functions of props** — no fetches, no globals, no
|
|
139
|
+
`useEffect` for data, no `Date.now()` / `Math.random()`. They are rendered to
|
|
140
|
+
static markup by the publisher and re-mounted constantly by the editor.
|
|
141
|
+
|
|
142
|
+
## `fill-spec.ts` — how AI fills the slots
|
|
143
|
+
|
|
144
|
+
Each decision `produces` one or more slot names. A slot filled by AI names its
|
|
145
|
+
decision in `produced_by`; a slot bound with `direct()` needs no decision, because
|
|
146
|
+
the fact is baked in at fill time.
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
import type { FillSpecShape } from "@homepages/template-kit";
|
|
150
|
+
|
|
151
|
+
export const fillSpec = {
|
|
152
|
+
reads: [],
|
|
153
|
+
decisions: [
|
|
154
|
+
{ id: "hero_image_assign", type: "image-assign", produces: ["hero_image"], tag_query: "category:exterior" },
|
|
155
|
+
{
|
|
156
|
+
id: "headline_text",
|
|
157
|
+
type: "text-block",
|
|
158
|
+
produces: ["headline"],
|
|
159
|
+
length_cap: 60,
|
|
160
|
+
structure: "single_sentence",
|
|
161
|
+
voice_prompt: "Confident premium-listing voice. Title Case.",
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
} as const satisfies FillSpecShape;
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## `fixtures.ts` — content for local preview
|
|
168
|
+
|
|
169
|
+
`base` is one fully-specified baseline: every required slot, with a real value.
|
|
170
|
+
`states` adds named scenarios that override part of it — use them for the
|
|
171
|
+
structural cases your section is designed to handle (a missing logo, a long
|
|
172
|
+
headline, an empty list).
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
import type { FixtureModule } from "@homepages/template-kit";
|
|
176
|
+
|
|
177
|
+
const fixtures: FixtureModule = {
|
|
178
|
+
base: {
|
|
179
|
+
slots: {
|
|
180
|
+
headline: "A mansard Victorian in the heart of Jamaica Plain",
|
|
181
|
+
listing_status: "for_sale",
|
|
182
|
+
hero_image: { photo_id: "p1", url: "https://example.com/a.jpg", alt: "Front elevation" },
|
|
183
|
+
},
|
|
184
|
+
options: { align: "left" },
|
|
185
|
+
},
|
|
186
|
+
states: {
|
|
187
|
+
centered: { label: "Centered", options: { align: "center" } },
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
export default fixtures;
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Build on the golden property, not on lorem ipsum
|
|
195
|
+
|
|
196
|
+
Preview is only as honest as the data behind it, so the kit ships a realistic dataset to
|
|
197
|
+
build fixtures from: a 12-unit building with long unit labels, a reserved unit and a
|
|
198
|
+
missing price — the cases that actually break a layout.
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
import { allPhotos, sampleProperty, sampleContacts, samplePois, sampleAmenities, mkResponsive } from "@homepages/template-kit";
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
| Export | What it is |
|
|
205
|
+
|---|---|
|
|
206
|
+
| `sampleProperty` | The full `PropertyFacts` bundle — address, units, contacts, coordinates |
|
|
207
|
+
| `allPhotos` | The gallery/hero photo pool (`PhotoFixture[]`) |
|
|
208
|
+
| `sampleContacts` | Attached agent cards (`ContactFixture[]`) |
|
|
209
|
+
| `samplePois` | Neighborhood points of interest with real coordinates (`PoiFixture[]`) |
|
|
210
|
+
| `sampleAmenities` | Curated amenity rows (`AmenityItem[]`) |
|
|
211
|
+
| `sampleHeroSlots` | A ready-made slot bundle for a hero-shaped section |
|
|
212
|
+
| `mkResponsive(url, width, height)` | Synthesizes a deterministic `ResponsiveImage` descriptor so `<Image responsive>` can be previewed and snapshotted |
|
|
213
|
+
|
|
214
|
+
## Variants — rendering differently based on the facts
|
|
215
|
+
|
|
216
|
+
A variant axis picks a case from the property's facts, before any content is
|
|
217
|
+
filled. Declare the axis in `meta.variants` with its `cases`, a `default` for when
|
|
218
|
+
facts are missing, and a pure `select` function. Your renderer receives the
|
|
219
|
+
resolved case on `props.variants`.
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
meta: {
|
|
223
|
+
displayName: "Floorplan",
|
|
224
|
+
variants: {
|
|
225
|
+
layout: {
|
|
226
|
+
cases: ["single", "multi"],
|
|
227
|
+
default: "single",
|
|
228
|
+
select: (input) => (input.units.length > 1 ? "multi" : "single"),
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
`select` receives a `TransformInput` and must be **pure** — it runs inside the
|
|
235
|
+
published bundle. A case it returns that is not in `cases` falls back to `default`.
|
|
236
|
+
|
|
237
|
+
## `manifest.json` — composing sections into a template
|
|
238
|
+
|
|
239
|
+
The template's `manifest.json` lists the ordered section instances that make up a
|
|
240
|
+
page. It is validated against the exported `TemplateManifest` schema.
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
---
|
|
2
|
+
purpose: The kit's two CSS entries, the Tailwind import rule, the TokenTheme a template supplies, and the breakpoint ladder.
|
|
3
|
+
status: living
|
|
4
|
+
related: [primitives.md, schema-system.md, eslint.md]
|
|
5
|
+
updated: 2026-07-14
|
|
6
|
+
---
|
|
7
|
+
# Theme and CSS
|
|
8
|
+
|
|
9
|
+
The kit owns the **contract**: the Tailwind utility namespace (`text-ink`,
|
|
10
|
+
`bg-surface-alt`, `rounded-md`), the breakpoint ladder, the reset, and the section box
|
|
11
|
+
model. Your template owns the **values** behind that namespace, declared once as a
|
|
12
|
+
`TokenTheme`.
|
|
13
|
+
|
|
14
|
+
Two CSS entries ship, and they are not interchangeable.
|
|
15
|
+
|
|
16
|
+
| Entry | What it is | How it loads |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| `@homepages/template-kit/styles.css` | The Tailwind v4 `@theme inline` alias layer + the breakpoint ladder + `@source` registration of the kit's compiled JS | imported into **your Tailwind entry**, after Tailwind |
|
|
19
|
+
| `@homepages/template-kit/base.css` | Cascade-layer order, the reset, document defaults, `.tr-section`, and the universal spacing / motion tokens | its **own stylesheet** on the page — not part of the Tailwind entry |
|
|
20
|
+
|
|
21
|
+
## Import Tailwind exactly once
|
|
22
|
+
|
|
23
|
+
```css
|
|
24
|
+
/* your CSS entry */
|
|
25
|
+
@import "tailwindcss";
|
|
26
|
+
@import "@homepages/template-kit/styles.css";
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`styles.css` deliberately does **not** `@import "tailwindcss"` itself. Exactly one
|
|
30
|
+
`@import "tailwindcss"` may exist per build graph — yours. Two double-emit Tailwind's
|
|
31
|
+
preflight reset (`box-sizing` declared twice, several KB of duplicate base layer), and
|
|
32
|
+
nothing errors to tell you.
|
|
33
|
+
|
|
34
|
+
`styles.css` also carries `@source "./**/*.js"`, which registers the kit's own shipped
|
|
35
|
+
`dist` for Tailwind's class scan. That is how the utility classes baked into the kit's
|
|
36
|
+
primitives (`Image`'s `bg-surface-alt`, `object-cover`, …) reach your compiled CSS; it
|
|
37
|
+
also overrides Tailwind's default exclusion of `node_modules`. You do not need to add a
|
|
38
|
+
`@source` for the kit yourself.
|
|
39
|
+
|
|
40
|
+
## The page's stylesheets
|
|
41
|
+
|
|
42
|
+
A published page loads, in order:
|
|
43
|
+
|
|
44
|
+
1. **`base.css`** — layer order, reset, `.tr-section`, universal tokens.
|
|
45
|
+
2. **Your theme's `:root` block** — the `--tr-*` values, produced by `compileThemeToCss`.
|
|
46
|
+
3. **Your compiled Tailwind bundle** — `@import "tailwindcss"` + `styles.css` + your
|
|
47
|
+
section markup's utilities.
|
|
48
|
+
|
|
49
|
+
`base.css` reads `--tr-color-ink`, `--tr-color-background`, `--tr-font-sans`,
|
|
50
|
+
`--tr-text-base` and `--tr-header-height` for the document defaults, so the theme block
|
|
51
|
+
must be present on the page too. The layer order it declares is:
|
|
52
|
+
|
|
53
|
+
```css
|
|
54
|
+
@layer theme, base, components, sections, template, utilities;
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Utilities come last, so a Tailwind utility always beats the reset's element defaults.
|
|
58
|
+
|
|
59
|
+
## `TokenTheme` — your template's values
|
|
60
|
+
|
|
61
|
+
A template's `theme.ts` exports a `TokenTheme`. `compileThemeToCss` turns it into the
|
|
62
|
+
`:root { --tr-*: … }` block the template's CSS bundle ships — deterministic (stable key
|
|
63
|
+
order, palette keys sorted), so the same theme always content-hashes identically.
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import { compileThemeToCss, type TokenTheme } from "@homepages/template-kit";
|
|
67
|
+
|
|
68
|
+
export const theme: TokenTheme = {
|
|
69
|
+
colors: { ink: "#111418", background: "#ffffff", /* …every role, see below */ },
|
|
70
|
+
palette: { "brand-gold": "#c8a248" }, // optional escape hatch
|
|
71
|
+
fonts: {
|
|
72
|
+
body: '"Inter", system-ui, sans-serif',
|
|
73
|
+
heading: '"Canela", Georgia, serif',
|
|
74
|
+
numeric: '"Inter", system-ui, sans-serif',
|
|
75
|
+
label: '"Inter", system-ui, sans-serif',
|
|
76
|
+
fontFaces: '@import url("https://fonts.example/inter.css");', // optional, emitted before :root
|
|
77
|
+
},
|
|
78
|
+
type: { xs: "0.75rem", sm: "0.875rem", base: "1rem", /* lg xl 2xl 3xl 4xl 5xl display */ },
|
|
79
|
+
radii: { sm: "2px", md: "6px", lg: "12px", full: "9999px" },
|
|
80
|
+
shadows: { sm: "…", md: "…", lg: "…" },
|
|
81
|
+
layout: { containerMax: "1280px", containerPad: "1.5rem", headerHeight: "72px" },
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const css = compileThemeToCss(theme);
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`TokenThemeSchema` validates it at runtime; the object is `.strict()`, so an unknown key
|
|
88
|
+
is an error rather than a silently-dead token. Every scale is **complete or invalid** —
|
|
89
|
+
there are no defaults to fall back on.
|
|
90
|
+
|
|
91
|
+
### Color roles
|
|
92
|
+
|
|
93
|
+
The role keys are fixed. Each maps 1:1 to a `--tr-color-*` variable, and the Tailwind
|
|
94
|
+
utility is named after **the variable**, not the role key — so `surfaceAlt` is
|
|
95
|
+
`bg-surface-alt`, and `primaryStrong` is `bg-primary-darker`. Read the mapping off the
|
|
96
|
+
exported `COLOR_ROLE_VARS` (and `FONT_ROLE_VARS`, `TYPE_STEP_VARS`, `RADIUS_VARS`,
|
|
97
|
+
`SHADOW_VARS`, `LAYOUT_VARS` for the other scales), or let your editor autocomplete the
|
|
98
|
+
`ColorRole` union:
|
|
99
|
+
|
|
100
|
+
| Role key | CSS variable | Utility |
|
|
101
|
+
|---|---|---|
|
|
102
|
+
| `ink` `inkSoft` `inkMute` | `--tr-color-ink[-soft|-mute]` | `text-ink`, `text-ink-soft`, `text-ink-mute` |
|
|
103
|
+
| `background` `surface` `surfaceAlt` | `--tr-color-background` / `-surface` / `-surface-alt` | `bg-surface-alt`, … |
|
|
104
|
+
| `border` `borderStrong` | `--tr-color-border[-strong]` | `border-border-strong`, … |
|
|
105
|
+
| `primary` `primaryFg` `primaryStrong` | `--tr-color-primary` / `-primary-fg` / `-primary-darker` | `bg-primary`, `text-primary-fg`, `bg-primary-darker` |
|
|
106
|
+
| `accent` `accentFg` `accentStrong` | `--tr-color-accent` / `-accent-contrast` / `-accent-strong` | `bg-accent`, `text-accent-contrast`, `bg-accent-strong` |
|
|
107
|
+
| `overlay` `overlayStrong` `overlayLight` | `--tr-color-overlay[-strong|-light]` | `bg-overlay`, … |
|
|
108
|
+
| `darkBg` `darkFg` `darkFgMute` `darkBorder` | `--tr-color-footer-bg` / `-footer-fg` / `-footer-fg-mute` / `-footer-divider` | `bg-footer-bg`, `text-footer-fg`, `border-footer-divider`, … |
|
|
109
|
+
|
|
110
|
+
`palette` is the open escape hatch: each key becomes `--tr-color-<key>`. Sections normally
|
|
111
|
+
use the semantic roles — reach for a brand swatch only when no role honestly fits.
|
|
112
|
+
|
|
113
|
+
A section carries no palette of its own, in server-rendered code or inside an island —
|
|
114
|
+
the `template-kit/no-hex` [lint rule](eslint.md#universal--no-island-exemption) bans a
|
|
115
|
+
hard-coded hex literal or CSS color function in a color context and does not exempt
|
|
116
|
+
`"use client"` files, unlike most of the preset's other rules.
|
|
117
|
+
|
|
118
|
+
## The breakpoint ladder
|
|
119
|
+
|
|
120
|
+
Eight canonical widths. 350 is the mobile-first **base** (no prefix); the other seven are
|
|
121
|
+
named breakpoints:
|
|
122
|
+
|
|
123
|
+
| Width | Prefix |
|
|
124
|
+
|---|---|
|
|
125
|
+
| 350 | *(base — no prefix)* |
|
|
126
|
+
| 390 | `bp390:` |
|
|
127
|
+
| 425 | `bp425:` |
|
|
128
|
+
| 512 | `bp512:` |
|
|
129
|
+
| 768 | `md:` |
|
|
130
|
+
| 1024 | `lg:` |
|
|
131
|
+
| 1440 | `bp1440:` |
|
|
132
|
+
| 1920 | `bp1920:` |
|
|
133
|
+
|
|
134
|
+
`md` and `lg` keep Tailwind's own names and values. Tailwind's off-ladder defaults
|
|
135
|
+
(`sm:`, `xl:`, `2xl:`) are reset to `initial` and **do not exist** — a section cannot come
|
|
136
|
+
to depend on a width nobody verifies it at. The same widths are exported as
|
|
137
|
+
`VERIFICATION_WIDTHS`, which is the set your section is screenshotted and verified
|
|
138
|
+
against. A swap-heavy section may still write a raw `@media` query at an off-ladder width.
|
|
139
|
+
|
|
140
|
+
## Recipe: `content-visibility` on long, image-heavy pages
|
|
141
|
+
|
|
142
|
+
A page that stacks many image-heavy sections can exhaust the browser's GPU/raster
|
|
143
|
+
memory — enough to make it evict a live WebGL map's layer on scroll, blanking the map.
|
|
144
|
+
`content-visibility: auto` fixes this by letting off-screen sections skip rendering, so
|
|
145
|
+
they stop consuming that memory.
|
|
146
|
+
|
|
147
|
+
The kit does **not** apply this for you — it has a real cost (below), and only you know
|
|
148
|
+
whether your page needs it. If it does, add the rule to the same CSS entry that does
|
|
149
|
+
`@import "tailwindcss"; @import "@homepages/template-kit/styles.css";` — the third
|
|
150
|
+
stylesheet the page loads:
|
|
151
|
+
|
|
152
|
+
```css
|
|
153
|
+
@layer template {
|
|
154
|
+
.tr-root .tr-section:not(header):not(.my-map-section) {
|
|
155
|
+
content-visibility: auto;
|
|
156
|
+
contain-intrinsic-size: auto 600px;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Layer placement matters: inside `@layer template`, a Tailwind utility (`@layer
|
|
162
|
+
utilities`, which comes later in the layer order) can always override this rule on a
|
|
163
|
+
given section. Left unlayered, it would beat every layer instead, including
|
|
164
|
+
utilities. Neither collides today — no Tailwind utility sets `content-visibility` —
|
|
165
|
+
but wrapping it in `@layer template` keeps that guarantee explicit rather than
|
|
166
|
+
accidental.
|
|
167
|
+
|
|
168
|
+
`.tr-root` is set on `<body>` at publish time and is **absent in the editor**, so the rule
|
|
169
|
+
is published-only — and so are its consequences. You will not see them in preview.
|
|
170
|
+
`contain-intrinsic-size: auto` remembers each section's rendered height, avoiding scroll jank.
|
|
171
|
+
|
|
172
|
+
**The cost.** `content-visibility` implies `contain: paint`, which **clips descendants to
|
|
173
|
+
the section box**. So:
|
|
174
|
+
|
|
175
|
+
- A full-screen overlay — a lightbox, a gallery, a modal — must be appended to
|
|
176
|
+
`document.body`, never rendered inside the section. Inside, it gets clipped.
|
|
177
|
+
- A section that must stay painted while off-screen (a live map), or whose dropdowns
|
|
178
|
+
escape its box, must be excluded — add its class to the `:not()` list, as
|
|
179
|
+
`.my-map-section` is above.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homepages/template-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "Authoring kit for HomePages marketing-section templates: schema system, contract primitives, theme tokens, and the template-kit CLI.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
"files": [
|
|
18
18
|
"dist",
|
|
19
19
|
"docs",
|
|
20
|
-
"tsconfig.json"
|
|
20
|
+
"tsconfig.json",
|
|
21
|
+
"tsconfig.base.json",
|
|
22
|
+
"CHANGELOG.md"
|
|
21
23
|
],
|
|
22
24
|
"bin": {
|
|
23
25
|
"template-kit": "dist/cli.js"
|
|
@@ -27,25 +29,40 @@
|
|
|
27
29
|
"types": "./dist/index.d.ts",
|
|
28
30
|
"default": "./dist/index.js"
|
|
29
31
|
},
|
|
32
|
+
"./fixtures": {
|
|
33
|
+
"types": "./dist/fixtures.d.ts",
|
|
34
|
+
"default": "./dist/fixtures.js"
|
|
35
|
+
},
|
|
36
|
+
"./island-runtime": {
|
|
37
|
+
"types": "./dist/island-runtime.d.ts",
|
|
38
|
+
"default": "./dist/island-runtime.js"
|
|
39
|
+
},
|
|
40
|
+
"./browser": {
|
|
41
|
+
"types": "./dist/browser.d.ts",
|
|
42
|
+
"default": "./dist/browser.js"
|
|
43
|
+
},
|
|
30
44
|
"./eslint": {
|
|
31
45
|
"types": "./dist/eslint.d.ts",
|
|
32
46
|
"default": "./dist/eslint.js"
|
|
33
47
|
},
|
|
34
48
|
"./styles.css": "./dist/styles.css",
|
|
35
|
-
"./
|
|
49
|
+
"./base.css": "./dist/base.css",
|
|
50
|
+
"./tsconfig": "./tsconfig.base.json",
|
|
36
51
|
"./package.json": "./package.json"
|
|
37
52
|
},
|
|
38
53
|
"sideEffects": [
|
|
39
54
|
"*.css"
|
|
40
55
|
],
|
|
41
56
|
"scripts": {
|
|
57
|
+
"prepack": "npm run build",
|
|
42
58
|
"build": "tsdown",
|
|
43
59
|
"typecheck": "tsc --noEmit",
|
|
44
60
|
"lint": "eslint .",
|
|
45
|
-
"test": "node --import tsx --test src
|
|
46
|
-
"lint:pkg": "publint --strict && attw --pack . --profile esm-only --exclude-entrypoints styles.css",
|
|
61
|
+
"test": "node --import tsx --test 'src/**/*.test.ts' 'src/**/*.test.tsx'",
|
|
62
|
+
"lint:pkg": "publint --strict && attw --pack . --profile esm-only --exclude-entrypoints styles.css base.css",
|
|
47
63
|
"verify:consumer": "node scripts/verify-consumer.mjs",
|
|
48
|
-
"
|
|
64
|
+
"size:islands": "node scripts/check-island-runtime-size.mjs",
|
|
65
|
+
"check": "npm run typecheck && npm run lint && npm run build && npm run size:islands && npm run test && npm run lint:pkg && npm run verify:consumer",
|
|
49
66
|
"changeset": "changeset",
|
|
50
67
|
"release": "changeset publish"
|
|
51
68
|
},
|
|
@@ -64,10 +81,13 @@
|
|
|
64
81
|
"@changesets/cli": "^2.29.8",
|
|
65
82
|
"@eslint/js": "^9.39.4",
|
|
66
83
|
"@tailwindcss/cli": "^4.3.0",
|
|
84
|
+
"@types/jsdom": "^21.1.7",
|
|
67
85
|
"@types/node": "^20.17.10",
|
|
68
86
|
"@types/react": "^19.0.0",
|
|
69
87
|
"@types/react-dom": "^19.0.0",
|
|
88
|
+
"esbuild": "^0.28.1",
|
|
70
89
|
"eslint": "^9.39.4",
|
|
90
|
+
"jsdom": "^26.1.0",
|
|
71
91
|
"publint": "^0.3.21",
|
|
72
92
|
"react": "^19.0.0",
|
|
73
93
|
"react-dom": "^19.0.0",
|
|
@@ -76,5 +96,9 @@
|
|
|
76
96
|
"tsx": "^4.19.2",
|
|
77
97
|
"typescript": "^5.7.2",
|
|
78
98
|
"typescript-eslint": "^8.60.1"
|
|
99
|
+
},
|
|
100
|
+
"dependencies": {
|
|
101
|
+
"@typescript-eslint/parser": "^8.60.1",
|
|
102
|
+
"zod": "^3.23.8"
|
|
79
103
|
}
|
|
80
104
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"lib": ["ES2022", "DOM"],
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noUncheckedIndexedAccess": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"isolatedModules": true,
|
|
13
|
+
"resolveJsonModule": true,
|
|
14
|
+
"forceConsistentCasingInFileNames": true
|
|
15
|
+
}
|
|
16
|
+
}
|
package/tsconfig.json
CHANGED
|
@@ -1,18 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
+
"extends": "./tsconfig.base.json",
|
|
2
3
|
"compilerOptions": {
|
|
3
|
-
"target": "ES2022",
|
|
4
|
-
"module": "ESNext",
|
|
5
|
-
"moduleResolution": "Bundler",
|
|
6
|
-
"lib": ["ES2022", "DOM"],
|
|
7
|
-
"jsx": "react-jsx",
|
|
8
|
-
"strict": true,
|
|
9
|
-
"noUncheckedIndexedAccess": true,
|
|
10
|
-
"esModuleInterop": true,
|
|
11
|
-
"skipLibCheck": true,
|
|
12
|
-
"isolatedModules": true,
|
|
13
|
-
"resolveJsonModule": true,
|
|
14
|
-
"forceConsistentCasingInFileNames": true,
|
|
15
4
|
"noEmit": true
|
|
16
5
|
},
|
|
17
|
-
"exclude": ["node_modules", "dist"]
|
|
6
|
+
"exclude": ["node_modules", "dist", ".tmp-consumer"]
|
|
18
7
|
}
|
package/dist/src-CrdHXijV.js
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import { jsx } from "react/jsx-runtime";
|
|
2
|
-
|
|
3
|
-
//#region src/kit-smoke.tsx
|
|
4
|
-
/**
|
|
5
|
-
* Boundary probe, not authoring surface. Its only job is to make the package
|
|
6
|
-
* boundary testable: React stays a peer, types resolve, and Tailwind's
|
|
7
|
-
* cross-package @source scan reaches this file's compiled output.
|
|
8
|
-
* Removed when the real contract primitives land.
|
|
9
|
-
*/
|
|
10
|
-
function KitSmoke({ label }) {
|
|
11
|
-
return /* @__PURE__ */ jsx("span", {
|
|
12
|
-
className: "bg-kit-smoke uppercase tracking-[0.4275em]",
|
|
13
|
-
children: label
|
|
14
|
-
});
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
//#endregion
|
|
18
|
-
//#region src/index.ts
|
|
19
|
-
/** The published version of this kit. Kept in sync with package.json by src/index.test.tsx. */
|
|
20
|
-
const KIT_VERSION = "0.0.0";
|
|
21
|
-
|
|
22
|
-
//#endregion
|
|
23
|
-
export { KitSmoke as n, KIT_VERSION as t };
|