create-kywi-app 0.6.3 → 0.6.5

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.
@@ -17,6 +17,27 @@ edit content in the admin, or do they need a developer?"* If the answer is
17
17
  Static code is for structure and brand chrome. Content — anything with words,
18
18
  images, prices, names, dates — belongs in Kywi.
19
19
 
20
+ ## Where the owner edits — the surfaces you're building for
21
+
22
+ "Editable" isn't an abstraction. It means the thing appears in one of three
23
+ shipped surfaces:
24
+
25
+ - **Admin → Content / Site Tree** — the fields on a node (which fields exist is
26
+ decided by the type, §2).
27
+ - **Admin → the layout editor** — regions, sections, columns, modules, page
28
+ variants, and section-level personalized/A-B containers with an arm switcher.
29
+ - **The public site itself** — a signed-in admin gets a toolbar on every public
30
+ page (page title, status, **Edit this page**), which opens that same layout
31
+ editor *in place*: the real page, the site's own stylesheet, chrome docked to
32
+ the viewport edges. Text-bearing modules are edited by typing on the page.
33
+
34
+ The build rule that follows: **prefer structures that show up in those
35
+ surfaces.** A headline in a `heading` module is editable in all three; the same
36
+ headline in a hand-written component is editable in none. Custom modules (§5)
37
+ must expose their copy as first-class props for the same reason — and a module
38
+ whose *first* declared prop is `text`, `textarea` or `richText` becomes
39
+ inline-editable on the front of the site for free.
40
+
20
41
  ## Before you build: design the content model and get sign-off
21
42
 
22
43
  The most expensive failure in a CMS build is a poorly planned content model —
@@ -57,6 +78,8 @@ the full proposal — ten minutes of sign-off beats re-entering fifty nodes.
57
78
  | Filter/group content by topic, industry, audience | Categories & tags, queried by feeds (§3) |
58
79
  | Copy-paste a CTA/banner/snippet across pages | Save it as a reusable component (§4) |
59
80
  | Write page sections as JSX | Build them as layout sections/modules the owner can edit (§5) |
81
+ | Build a page as one column of full-width headings and paragraphs | Compose: section bands, columns, object modules (§5) |
82
+ | Put a custom module's editable copy in a `json` prop | One `text`/`textarea`/`richText` prop per field the owner edits (§5) |
60
83
  | Hand-code a `<form>` | Build it in the Forms admin, place the Form module (§6) |
61
84
  | Drop images into `/public` | Upload to the Media library (§7) |
62
85
  | Build segment-specific or experimental UI unprompted | Ask the owner first — offer personalization/A-B with examples (§8) |
@@ -113,6 +136,28 @@ by `kywi migrate`. When in doubt, admin-created.
113
136
  node's own page is the detail view. Listing + detail with zero custom code.
114
137
  - Don't shadow built-in fields (slug, body) with custom ones — Kywi will block
115
138
  it; use the built-ins.
139
+ - The scaffold ships one config type (`page`) so a fresh site can publish
140
+ immediately. That's the floor, not the pattern — add your types in the Type
141
+ Designer, and only put a type in `contentTypes` when code depends on its shape.
142
+
143
+ **Creating types programmatically.** The admin UI is one client of the REST API;
144
+ an agent uses the same endpoints, and the order matters:
145
+
146
+ 1. `POST /api/v1/content-types` — create the type.
147
+ 2. `POST /api/v1/content-types/:type/fields` — add each field.
148
+ 3. `POST /api/v1/content-types/apply` — **materialize the schema** (diffs the
149
+ type and runs the CREATE/ALTER for its side table).
150
+ 4. *Then* create nodes — before the apply step the side table doesn't exist and
151
+ the write fails (cleanly rolled back since 0.6.3, but still a failure).
152
+
153
+ **Seed with an idempotent script.** Put structural setup in a
154
+ `scripts/seed-content.mjs` that logs in (`POST /api/v1/auth/login`) and *checks
155
+ before creating* every object — list types → create the missing, list fields →
156
+ add the missing, same for taxonomy, audiences, experiments, self-ID. It is
157
+ re-runnable, reviewable in git, and rebuilds the site on a fresh database. Build
158
+ pages, layouts and feeds over MCP or in the admin instead. The script seeds
159
+ structure; it doesn't own content — once the owner edits a node, a re-run must
160
+ not clobber it.
116
161
 
117
162
  ## 3. Taxonomy: categories & tags
118
163
 
@@ -146,22 +191,142 @@ newsletter signup blocks, campaign snippets, "as seen in" strips — should be a
146
191
  ## 5. Page layouts & modules — not hardcoded JSX
147
192
 
148
193
  Pages the owner should be able to restructure — landing pages, the home page,
149
- campaign pages — should be built **in the layout editor** (sections, columns,
150
- modules), rendered through Kywi's layout renderer. A scaffolded app already
151
- renders saved layouts on every content page; keep it that way.
152
-
153
- - The built-in module palette (hero, cards, CTA, testimonial, feed display,
154
- pricing, FAQ, forms, comments, media…) covers most marketing-site needs.
155
- - Brand-specific blocks the palette lacks: build a **custom module** once
156
- (`defineModule` in `kywi.config.ts` + a React component registered in the
157
- host's module map — the scaffold's `lib/modules.tsx` shows the shape). The
158
- developer owns the component; the owner places and configures instances.
159
- - Create **saved layouts** (Layouts admin) as page templates — "Landing page",
160
- "Case study" — so new pages start from a consistent skeleton instead of a
161
- blank canvas.
162
- - Hand-written JSX pages are fine for genuinely fixed chrome (a bespoke 404,
163
- legal boilerplate shells) — but if marketing will ever want to swap a
164
- headline, it's a layout page.
194
+ campaign pages — should be built **in the layout editor** (regions → sections
195
+ columns → modules), rendered through Kywi's layout renderer. A scaffolded app
196
+ already renders saved layouts on every content page; keep it that way.
197
+
198
+ **Compose.** A page is not a stack of full-width `heading` + `richText` blocks.
199
+ That is the shape an agent falls into when it doesn't know the palette, and it
200
+ reads as a styled document rather than a site. Sections are bands (each owns its
201
+ background and padding), `columns` splits them, and the object modules below do
202
+ the rest.
203
+
204
+ ### The built-in palette
205
+
206
+ `name` is the exact `type` value in a layout node and in MCP `add_module`. A
207
+ custom module that reuses one of these names **overrides** the built-in
208
+ everywhere.
209
+
210
+ **Layout** — the skeleton of a page.
211
+
212
+ | `name` | Purpose |
213
+ |---|---|
214
+ | `section` | Top-level band in a region; owns background colour and vertical padding |
215
+ | `container` | Width-capped, padded wrapper inside a section |
216
+ | `columns` | Multi-column split (`preset` picks the ratio, `gap` the spacing) |
217
+ | `spacer` | Fixed vertical gap |
218
+ | `divider` | Horizontal rule (colour, thickness) |
219
+
220
+ **Elements** — the primitives.
221
+
222
+ | `name` | Purpose |
223
+ |---|---|
224
+ | `richText` | WYSIWYG prose — the default home for owner-authored copy |
225
+ | `heading` | One heading, with its level (h1–h6) |
226
+ | `text` | Plain paragraph text |
227
+ | `image` | Media-library image with alt text and caption |
228
+ | `button` | Labelled link with a style variant |
229
+ | `html` | Raw HTML escape hatch (sanitized) — not a layout tool |
230
+ | `blockquote` | Quote with author and citation URL |
231
+ | `badge` | Small label/pill, optionally linked |
232
+ | `icon` | Named icon with size, colour, accessible label |
233
+ | `video` | Video file with poster, autoplay/loop/muted |
234
+ | `embed` | Third-party embed code |
235
+
236
+ **Objects** — the composed blocks.
237
+
238
+ | `name` | Purpose |
239
+ |---|---|
240
+ | `card` | Title + body + image + link — the generic tile |
241
+ | `accordion` | One collapsible title/content panel |
242
+ | `tabs` | Tabbed panels (`tabs` JSON prop) |
243
+ | `carousel` | Rotating slides (`items` JSON prop, autoplay/interval) |
244
+ | `testimonial` | Quote + author + role + avatar |
245
+ | `hero` | Headline, subtext, background image, one CTA button |
246
+ | `cta` | Headline + subtext + button — the conversion band |
247
+ | `pricing` | One plan: name, price, period, features, button |
248
+ | `faq` | One question/answer pair, optionally open by default |
249
+ | `alert` | Inline notice (info/warning/…), optionally dismissible |
250
+ | `countdown` | Counts down to a target date |
251
+ | `feedDisplay` | Renders a Feed (§1) — title, summary, image per item |
252
+ | `tagCloud` | Weighted tag list |
253
+ | `relatedContent` | Manual list of related links (`items` JSON prop) |
254
+ | `categorySummary` | One category: name, description, image, count |
255
+ | `categoryList` | Auto-listed child categories under a parent slug |
256
+ | `comments` | Moderated comment thread for the current node |
257
+
258
+ **Navigation** — wayfinding.
259
+
260
+ | `name` | Purpose |
261
+ |---|---|
262
+ | `breadcrumbs` | Trail of ancestor links |
263
+ | `navMenu` | A named menu (`menuSlug`) rendered to a depth |
264
+ | `navigation` | Site navigation with a style variant and depth |
265
+ | `siteMap` | Nested list of the site tree |
266
+ | `searchBox` | Compact search input posting to a results URL |
267
+ | `search` | Full search form (method, label, results URL) |
268
+
269
+ **Personalization**, **Compliance**, and **Saved** (references to things
270
+ authored elsewhere in the admin).
271
+
272
+ | `name` | Purpose |
273
+ |---|---|
274
+ | `variantContainer` | Module-level arms revealed client-side per audience — read §8 first |
275
+ | `personalizationBadge` | Mount point for the "you're seeing X" transparency control |
276
+ | `socialShare` | Share links for a URL/title across networks |
277
+ | `map` | Embedded map at a lat/lng or address |
278
+ | `cookieConsent` | Consent banner with message + privacy-policy link |
279
+ | `component` | Places a saved reusable component by slug (§4) |
280
+ | `form` | Renders a form built in the Forms admin (§6) |
281
+ | `formEmbed` | Embeds a form by slug — lighter-weight placement of the same |
282
+
283
+ ### Which do I reach for? (in order)
284
+
285
+ 1. **A built-in already does it** → use it. Read the tables before inventing
286
+ anything; "the palette is thin" is almost always "I didn't look".
287
+ 2. **Prose, a heading, an image, a link** → `richText` / `heading` / `image` /
288
+ `button`. Never build a module to hold copy.
289
+ 3. **A repeating set of items** → a collection: folder + Feed + `feedDisplay`
290
+ (§1). Know the ceiling: Feed Display renders **title, summary and image
291
+ only**. If the cards need a price or a job title, denormalize it into
292
+ `summary` at authoring time, or build a custom module that reads the feed.
293
+ 4. **The same designed section on several pages** → build it once and **Save as
294
+ Component** (§4). Zero code, live references, owner-editable.
295
+ 5. **A composition or brand block the palette genuinely lacks** — media⇄text
296
+ split, stat callout, steps band, tabbed code sample, diagram frame → **one
297
+ custom module, reused**. Not a one-off `html` blob pasted per page.
298
+
299
+ ### Custom modules
300
+
301
+ Three steps, all required: `defineModule({ name, label, component, category,
302
+ props })` in `kywi.config.ts` `modules: []`; a `'use client'` React component
303
+ taking `{ props }: { props: Record<string, unknown> }`; an entry in
304
+ `lib/modules.tsx` `moduleComponents` keyed by the same `name`. Skip the third
305
+ and the module renders "Unknown module" in the editor *and* on the live page.
306
+ Full recipe, worked example and hazards: the **`kywi-custom-modules` skill**.
307
+
308
+ **Design the props for the owner, not for yourself.** The props panel gives the
309
+ owner real controls for `text`/`textarea`/`richText` (text inputs),
310
+ `image`/`file` (media picker), `boolean` (checkbox) and `number`/`date`/`color`
311
+ (native inputs); `select` and `multiSelect` render a real dropdown or checkbox
312
+ group when the prop declares `options` — `{ value, label }` pairs, or bare
313
+ strings where the string serves as both — so declare options for every closed
314
+ choice instead of trusting the owner to type the right value. `slug` and
315
+ `relationship` have no options field and are still plain text inputs; that's
316
+ the one place to name the allowed values in the label. A `json` prop is a
317
+ raw JSON textarea — developer territory. A "three stats" module with `stats: {
318
+ type: 'json' }` has taken the copy away from the owner; the same module with
319
+ `stat1Value` / `stat1Label` … as `text` props hasn't.
320
+
321
+ **Declare the primary copy prop first.** The front-of-site editor makes a
322
+ module's *first* prop inline-editable on the page when its type is `text`,
323
+ `textarea` or `richText` — so `props: { headline, … }` can be typed over
324
+ directly on the live page, and `props: { variant, headline, … }` cannot.
325
+
326
+ Also: create **saved layouts** (Layouts admin) as page templates — "Landing
327
+ page", "Case study" — so new pages start from a consistent skeleton. Hand-written
328
+ JSX stays fine for genuinely fixed chrome (a bespoke 404, legal boilerplate) —
329
+ but if marketing will ever want to swap a headline, it's a layout page.
165
330
 
166
331
  ## 6. Forms: always the Forms builder
167
332
 
@@ -209,6 +374,48 @@ default variant must stand alone (personalization is progressive enhancement);
209
374
  one experiment per conversion goal, and let it conclude before layering more.
210
375
  If no, skip it — the machinery is there when they grow into it.
211
376
 
377
+ ### Variant containers: pick the right shape
378
+
379
+ Two different things are called `variantContainer`, and they are not
380
+ interchangeable.
381
+
382
+ - **Section-level** — a *region* node, sibling of sections (`defaultSections` +
383
+ `variants[].sections`). Resolved **on the server** from the visitor's audience
384
+ or experiment arm, so the first paint is already correct: no flash, and the
385
+ page is complete with JavaScript off. This is the default for anything the
386
+ server can know — rule-based audiences (UTM, referrer, the `kywi_audience`
387
+ pin) and A/B experiments. Since 0.6.3 it also renders in the layout editor as
388
+ a badged block with an **arm switcher**, each arm editable with the ordinary
389
+ section/column/module tools, so server-resolution no longer costs the owner
390
+ their editing surface.
391
+ - **Module-level** — the `variantContainer` *module*, placed in a column; its
392
+ arms are HTML strings (`defaultContent`, `variants: [{audienceId, label,
393
+ content}]`). Every arm ships in the HTML (default visible, the rest
394
+ `display:none`) and the browser runtime reveals the matching one. Use it
395
+ **only for signals the server cannot read at first paint** — in practice, a
396
+ self-ID answer held in `localStorage`.
397
+
398
+ Four traps, each of which fails silently:
399
+
400
+ 1. **Use the layout module, not the React component.** `VariantContainer.tsx`
401
+ (`core/src/components/personalization/`) has a loading branch that renders
402
+ the skeleton and nothing else — a JS-off visitor gets an empty container —
403
+ and its `ab_test` path draws a fresh `Math.random()` per render, so the same
404
+ visitor sees a different arm on every refresh. The layout module
405
+ (`VariantContainerModule`) always renders the default arm.
406
+ 2. **Leave `skeleton` off.** With `skeleton: true` the container ships
407
+ `aria-busy="true"` and only the client runtime clears it — a JS-off visitor
408
+ sits in a loading state forever.
409
+ 3. **An audience match beats the experiment.** `resolveVariant` returns the
410
+ audience arm first, so a visitor who matches an audience never enters the
411
+ split. Don't run an experiment and an audience variant on the same container.
412
+ 4. **The client runtime needs `/kywi.js`.** Self-ID, behavioral re-evaluation
413
+ and the transparency badge no-op silently unless the built
414
+ `@kywi-software/js` bundle is at `public/kywi.js`.
415
+
416
+ Standing rule: **the default arm must be complete on its own.** It is what
417
+ search engines, answer engines, and every opted-out visitor receive.
418
+
212
419
  ## 9. Editorial workflow: drafts, review, versions
213
420
 
214
421
  For any site with more than one author — or an owner who wants a safety net —
@@ -288,6 +495,8 @@ early, so ask at scoping time.
288
495
  - Marketing imagery in `/public`.
289
496
  - A "blog" that is a folder of `.mdx` files the owner can't edit.
290
497
  - One-off content types created in config for shapes the owner will evolve.
498
+ - A page built as a flat stack of full-width `heading` + `richText` modules.
499
+ - A custom module whose owner-facing copy lives in a `json` prop.
291
500
  - Personalization built speculatively, with no owner request behind it.
292
501
  - Direct-publish-only workflow on a multi-author site.
293
502
 
@@ -0,0 +1,182 @@
1
+ ---
2
+ name: kywi-custom-modules
3
+ description: Use when a page needs a block the built-in module palette doesn't have — a media/text split, stat callout, steps band, tabbed code sample, diagram frame, branded hero — or whenever you catch yourself pasting bespoke markup into an `html` module or writing a page section as JSX. Covers when a custom module is the right call, the three-step recipe, owner-editable props, and the hazards that break SSR, JS-off visitors, or the owner's editing surface.
4
+ ---
5
+
6
+ # Build a custom module (only when the palette falls short)
7
+
8
+ A custom module is a React component the *developer* owns and the *owner*
9
+ places and configures. It's the right tool for composition the built-in palette
10
+ lacks — and the wrong tool for anything the palette already does, for copy, and
11
+ for one-off page decoration.
12
+
13
+ ## Workflow
14
+
15
+ ### 1. Confirm you need one
16
+
17
+ Walk the ladder in AGENTS.md §5 first: built-in module → `richText`/`heading`/
18
+ `image`/`button` → collection + `feedDisplay` → saved component → custom module.
19
+
20
+ **Build one when:**
21
+
22
+ - The page needs a *composition* the palette has no shape for: media⇄text
23
+ split, stat/metric callout row, numbered steps band, tabbed code sample,
24
+ diagram frame, a hero with a product visual beside the copy.
25
+ - The same brand-specific block will recur across pages and needs consistent
26
+ markup and styling.
27
+ - A built-in is 90% right but needs different markup — register a module with
28
+ the built-in's `name` to **override** it everywhere (editor and public site).
29
+
30
+ **Don't build one when:**
31
+
32
+ - A built-in covers it. Read the §5 palette tables; "the palette is thin" is
33
+ almost always "I didn't look".
34
+ - It's copy. Copy goes in `richText` — a module per paragraph is a smell.
35
+ - It's a repeating set of items. That's a collection (folder + feed +
36
+ `feedDisplay`), not a module with a hardcoded array.
37
+ - It'd be used once, on one page. Compose it from built-ins, or if the design
38
+ is genuinely bespoke chrome, keep it in the page component and accept that
39
+ the owner can't edit it — deliberately, and say so at handover.
40
+ - You'd need it because the owner's content doesn't fit `feedDisplay`'s
41
+ title/summary/image ceiling. Denormalizing into `summary` is usually cheaper.
42
+
43
+ ### 2. Design the props before writing the component
44
+
45
+ The props *are* the owner's editing surface. Decide, field by field, who edits
46
+ it — then pick the type:
47
+
48
+ | Prop type | Editor control | Use for |
49
+ |---|---|---|
50
+ | `text` | text input | headlines, eyebrows, labels, stat values |
51
+ | `textarea` | multi-line input | short body copy, quotes |
52
+ | `richText` | rich-text input | owner-authored HTML body copy |
53
+ | `image` / `file` | media picker | anything from the Media library |
54
+ | `boolean` | checkbox | layout flips (`reverse`), show/hide |
55
+ | `number` / `date` / `color` | native inputs | counts, deadlines, accents |
56
+ | `select` / `multiSelect` | dropdown / checkbox group (needs `options`) | closed choices — declare `options: [{ value, label }, …]` (bare strings also work) per prop |
57
+ | `slug` / `relationship` | plain text input | constrained values with no options field yet — name the allowed values in the label |
58
+ | `json` | raw JSON textarea | **developer territory** — structure the owner never edits |
59
+
60
+ Two rules that decide whether the module is really editable:
61
+
62
+ - **Keep owner copy out of `json`.** `stats: {type:'json'}` hands the owner a
63
+ JSON blob; `stat1Value` / `stat1Label` / `stat2Value` … as `text` props hands
64
+ them fields. Use `json` only for structure that never changes after build.
65
+ - **Declare the primary copy prop first.** The front-of-site editor makes a
66
+ module's *first* prop inline-editable on the live page when its type is
67
+ `text`, `textarea` or `richText`. Put the headline first; put `variant` and
68
+ layout switches after it.
69
+
70
+ ### 3. Register it — `defineModule` in `kywi.config.ts`
71
+
72
+ ```ts
73
+ import { defineKywiConfig, defineModule } from '@kywi-software/core/config'
74
+
75
+ export default defineKywiConfig({
76
+ // …sites, themes, contentTypes…
77
+ modules: [
78
+ defineModule({
79
+ name: 'splitFeature', // the layout node `type` + MCP add_module type
80
+ label: 'Media / text split', // what the owner sees in the palette
81
+ component: 'splitFeature', // the key you'll map in lib/modules.tsx
82
+ category: 'Marketing', // groups it in the palette
83
+ props: {
84
+ heading: { type: 'text', label: 'Heading' }, // first = inline-editable
85
+ body: { type: 'richText', label: 'Body' },
86
+ image: { type: 'image', label: 'Image' },
87
+ imageAlt: { type: 'text', label: 'Image alt text' },
88
+ layout: {
89
+ type: 'select',
90
+ label: 'Layout',
91
+ options: [
92
+ { value: 'split', label: 'Media / text split' },
93
+ { value: 'stacked', label: 'Stacked' },
94
+ ],
95
+ defaultValue: 'split',
96
+ }, // closed choice = real dropdown
97
+ reverse: { type: 'boolean', label: 'Image on the left', defaultValue: false },
98
+ },
99
+ }),
100
+ ],
101
+ })
102
+ ```
103
+
104
+ ### 4. Write the component
105
+
106
+ ```tsx
107
+ // lib/split-feature.tsx
108
+ 'use client'
109
+ import { resolveMediaRef } from '@kywi-software/core/layout'
110
+
111
+ export function SplitFeature({ props }: { props: Record<string, unknown> }) {
112
+ const src = resolveMediaRef(props.image) // media UUID → public file URL
113
+ return (
114
+ <div className={`split-feature${props.reverse === true ? ' is-reversed' : ''}`}>
115
+ <div className="split-feature__copy">
116
+ <h2>{String(props.heading ?? '')}</h2>
117
+ <div dangerouslySetInnerHTML={{ __html: String(props.body ?? '') }} />
118
+ </div>
119
+ {src ? <img src={src} alt={String(props.imageAlt ?? '')} /> : null}
120
+ </div>
121
+ )
122
+ }
123
+ ```
124
+
125
+ Every prop is `unknown` — coerce and default each one. A module that throws on a
126
+ missing prop breaks the editor canvas as well as the page.
127
+
128
+ ### 5. Map it in `lib/modules.tsx`
129
+
130
+ ```tsx
131
+ import { SplitFeature } from './split-feature'
132
+
133
+ export const moduleComponents: ModuleComponentMap = {
134
+ splitFeature: SplitFeature,
135
+ }
136
+ ```
137
+
138
+ This one map is passed to both `KywiAdminApp` and `KywiLayout`, so the module
139
+ renders identically in the editor canvas and live. **Skip this step and the
140
+ module renders "Unknown module" in both** — the most common custom-module bug.
141
+ The host merges `{ ...BUILT_IN_MODULE_COMPONENTS, ...moduleComponents }`, so an
142
+ entry named after a built-in replaces it.
143
+
144
+ ### 6. Hazards checklist
145
+
146
+ - **Unique ids need `React.useId()`.** A module-scope counter produces different
147
+ ids on server and client and breaks hydration. Same for `Date.now()` and
148
+ `Math.random()` in render.
149
+ - **Escape owner text inside `<script type="application/ld+json">`.** Replace
150
+ every `<` with the JS escape `\u003c`, break up `-->`, and escape the line
151
+ separators U+2028/U+2029 (matched via
152
+ `String.fromCharCode(0x2028, 0x2029)`) — otherwise a stray character in owner
153
+ copy ends the script tag early.
154
+ - **`dangerouslySetInnerHTML` is the pattern for `richText`/HTML props** — the
155
+ values are sanitized by Kywi before storage and on render. Don't reach for it
156
+ for plain `text` props.
157
+ - **Stay safe with JavaScript off.** A `'use client'` module still server-renders
158
+ its initial HTML — but anything that only appears after an effect is invisible
159
+ to crawlers and JS-off visitors. Interactivity is progressive enhancement over
160
+ complete server-rendered markup.
161
+ - **Style through theme tokens** (`var(--kywi-color-primary)`, `--kywi-spacing-*`)
162
+ in the site stylesheet, not hardcoded hex — the owner retunes the theme from
163
+ `kywi.config.ts` and your module should follow.
164
+ - **Don't fetch in the module.** Data comes from props (or, for feeds, the
165
+ host's server-side hydration). A module that fetches breaks the editor canvas.
166
+
167
+ ### 7. Verify
168
+
169
+ 1. Restart the dev server (config changes need a restart).
170
+ 2. Open the layout editor: the module appears in the palette under its
171
+ `category`, with its `label`.
172
+ 3. Place it, fill every prop from the props panel, save — it renders in the
173
+ canvas (not "Unknown module") and on the public page.
174
+ 4. Open the public page as a signed-in admin → **Edit this page**: the first
175
+ text prop is editable by typing on the page.
176
+ 5. Load the page with JavaScript disabled: the content is all there.
177
+
178
+ ### 8. Handover note
179
+
180
+ Record the module in `CONTENT-MODEL.md` (reusable components / modules) with one
181
+ line per prop: what the owner changes and where. A module the owner doesn't know
182
+ exists is a module they'll ask a developer to change.
package/lib/templates.mjs CHANGED
@@ -102,7 +102,7 @@ function packageJson(a) {
102
102
  /** @param {Answers} a */
103
103
  function kywiConfig(a) {
104
104
  const providers = a.authProviders.map((p) => `'${p}'`).join(', ')
105
- return `import { defineKywiConfig, defineSite, defineTheme } from '@kywi-software/core/config'
105
+ return `import { defineKywiConfig, defineSite, defineTheme, defineModule } from '@kywi-software/core/config'
106
106
  import { assertProductionAuthSecret } from '@kywi-software/core/host'
107
107
 
108
108
  // Refuse to build/start in production with a missing or dev-fallback AUTH_SECRET
@@ -179,9 +179,14 @@ export default defineKywiConfig({
179
179
  }),
180
180
  ],
181
181
 
182
- // Content types you can author in the admin. "Page" ships by default so a
183
- // fresh site can create and publish pages immediately; add your own here
184
- // (blog posts, events, …) and re-run \`pnpm migrate\`.
182
+ // Config-defined content types. "Page" ships so a fresh site can create and
183
+ // publish pages immediately that's the floor, not the pattern.
184
+ //
185
+ // DEFAULT: create your own types in the admin's **Type Designer**
186
+ // (admin → Content Types). Admin-created types are runtime-managed, so the
187
+ // site owner can add a field next year without a developer or a migration.
188
+ // Add a type HERE only when the *developer* must own its shape — code depends
189
+ // on it, it's reviewed in git, it migrates with \`pnpm migrate\`.
185
190
  contentTypes: [
186
191
  {
187
192
  name: 'page',
@@ -190,6 +195,29 @@ export default defineKywiConfig({
190
195
  fields: [],
191
196
  },
192
197
  ],
198
+
199
+ // Custom layout modules (\`defineModule\`) — the palette additions this project
200
+ // owns. Register a module here, then map its \`name\` → the React component that
201
+ // renders it in \`lib/modules.tsx\` (both the admin editor and the public site
202
+ // read that map). Reach for one only when the built-in palette genuinely lacks
203
+ // the composition; see AGENTS.md §5 and the \`kywi-custom-modules\` skill.
204
+ //
205
+ // modules: [
206
+ // defineModule({
207
+ // name: 'splitFeature', // layout node \`type\` + MCP add_module type
208
+ // label: 'Media / text split', // what the owner sees in the palette
209
+ // component: 'splitFeature', // the key you map in lib/modules.tsx
210
+ // category: 'Marketing',
211
+ // props: {
212
+ // // First text prop = inline-editable on the live page. Keep owner
213
+ // // copy in text/textarea/richText props, never in a json blob.
214
+ // heading: { type: 'text', label: 'Heading' },
215
+ // body: { type: 'richText', label: 'Body' },
216
+ // image: { type: 'image', label: 'Image' },
217
+ // },
218
+ // }),
219
+ // ],
220
+ modules: [],
193
221
  })
194
222
  `
195
223
  }
@@ -2150,7 +2178,10 @@ in \`kywi.config.ts\` (\`modules: [defineModule({ name: 'pricingTable', … })]\
2150
2178
  then map its \`name\` to the React component that renders it in \`lib/modules.tsx\`.
2151
2179
  That one map is passed to both the admin editor and the public site, so a custom
2152
2180
  module renders identically in the canvas and live (it shows an "Unknown module"
2153
- placeholder until you add the entry). See the worked example in \`lib/modules.tsx\`.
2181
+ placeholder until you add the entry). See the commented example in
2182
+ \`kywi.config.ts\`, the worked example in \`lib/modules.tsx\`, and the
2183
+ \`kywi-custom-modules\` skill for when a custom module is the right call at all —
2184
+ the built-in palette (47 modules) covers most of what a site needs.
2154
2185
 
2155
2186
  ### Create your first page
2156
2187
 
@@ -2171,12 +2202,10 @@ ${themingBlock}
2171
2202
  ## Project layout
2172
2203
 
2173
2204
  \`\`\`
2174
- kywi.config.ts your config: sites, themes, content types, auth, mode, admin.features
2205
+ kywi.config.ts your config: sites, themes, content types, custom modules, auth, mode, admin.features
2175
2206
  AGENTS.md guidance for AI agents working on this site (Kywi's building patterns)
2176
2207
  CLAUDE.md points AI agents to AGENTS.md
2177
- .claude/skills/kywi-content-model/SKILL.md content-model skill (loaded automatically)
2178
- .claude/skills/kywi-collections/SKILL.md collections skill (loaded automatically)
2179
- .claude/skills/kywi-personalization/SKILL.md personalization skill (loaded automatically)
2208
+ ${SKILLS.map(({ slug }) => `.claude/skills/${slug}/SKILL.md ${slug.replace(/^kywi-/, '')} skill (loaded automatically)`).join('\n')}
2180
2209
  middleware.ts auth gate + session refresh + cookie→bearer bridge
2181
2210
  next.config.mjs required Next config to consume @kywi-software/core
2182
2211
  lib/kywi.ts server runtime (DB, API handler, content scope)
@@ -2223,6 +2252,38 @@ function agentPatternsDoc() {
2223
2252
  return _agentPatternsDoc
2224
2253
  }
2225
2254
 
2255
+ /**
2256
+ * Version of the agent-guidance SURFACE (this AGENTS.md shape + the skill set),
2257
+ * independent of the package version. Bump when the guidance changes in a way a
2258
+ * retrofitted app should pick up (new skill, restructured patterns doc), so an
2259
+ * app can compare its stamp against a newer create-kywi-app and know it's stale.
2260
+ */
2261
+ export const GUIDANCE_VERSION = 1
2262
+
2263
+ /**
2264
+ * This package's own version, read from its package.json (resolved relative to
2265
+ * THIS module, same mechanism as {@link agentPatternsDoc}). Cached.
2266
+ * @returns {string}
2267
+ */
2268
+ let _pkgVersion
2269
+ function packageVersion() {
2270
+ if (_pkgVersion === undefined) {
2271
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json')
2272
+ _pkgVersion = JSON.parse(readFileSync(pkgPath, 'utf8')).version
2273
+ }
2274
+ return _pkgVersion
2275
+ }
2276
+
2277
+ /**
2278
+ * The staleness stamp emitted as AGENTS.md's first line — an HTML comment, so it
2279
+ * is invisible when the file is rendered but greppable by tooling and by an agent
2280
+ * asking "is this project's guidance current?".
2281
+ * @returns {string}
2282
+ */
2283
+ export function guidanceStamp() {
2284
+ return `<!-- kywi-agent-guidance v${GUIDANCE_VERSION} (create-kywi-app ${packageVersion()}) -->`
2285
+ }
2286
+
2226
2287
  /**
2227
2288
  * Kywi's Claude Code project skills — each ships as a package asset and is
2228
2289
  * embedded verbatim into every generated app's .claude/skills/<slug>/SKILL.md
@@ -2235,6 +2296,9 @@ export const SKILLS = [
2235
2296
  { slug: 'kywi-content-model', asset: 'kywi-content-model-skill.md' },
2236
2297
  // When adding any collection: folder + feed + Feed Display, end to end.
2237
2298
  { slug: 'kywi-collections', asset: 'kywi-collections-skill.md' },
2299
+ // When the built-in module palette falls short: the defineModule recipe,
2300
+ // owner-editable props, and the SSR/JS-off hazards.
2301
+ { slug: 'kywi-custom-modules', asset: 'kywi-custom-modules-skill.md' },
2238
2302
  // When the owner wants personalization/A-B: confirm the use case, then build.
2239
2303
  { slug: 'kywi-personalization', asset: 'kywi-personalization-skill.md' },
2240
2304
  ]
@@ -2406,10 +2470,13 @@ export function agentsMd(a, landmarks = scaffoldLandmarks(a.mode)) {
2406
2470
  skill, loaded automatically before building anything.`)
2407
2471
  bullets.push(`- \`.claude/skills/kywi-collections/SKILL.md\` — collections skill, loaded
2408
2472
  automatically when adding any collection.`)
2473
+ bullets.push(`- \`.claude/skills/kywi-custom-modules/SKILL.md\` — custom-module skill, loaded
2474
+ automatically when the built-in module palette falls short.`)
2409
2475
  bullets.push(`- \`.claude/skills/kywi-personalization/SKILL.md\` — personalization skill,
2410
2476
  loaded automatically when the owner wants personalization or A/B testing.`)
2411
2477
 
2412
- const header = `# Agent guide — ${a.projectName}
2478
+ const header = `${guidanceStamp()}
2479
+ # Agent guide — ${a.projectName}
2413
2480
 
2414
2481
  ${intro}
2415
2482
  ${modeSentence}
@@ -2451,7 +2518,7 @@ holds Kywi's official building patterns and a map of where things live in this a
2451
2518
 
2452
2519
  Core principle: **model content in the CMS instead of hardcoding it** — see \`AGENTS.md\`.
2453
2520
 
2454
- Before building out a new content area, use the \`kywi-content-model\` skill (design the model, get sign-off) — skills in \`.claude/skills/\` also cover collections and personalization.
2521
+ Before building out a new content area, use the \`kywi-content-model\` skill (design the model, get sign-off) — skills in \`.claude/skills/\` also cover collections, custom modules, and personalization.
2455
2522
  `
2456
2523
  }
2457
2524
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-kywi-app",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "description": "Scaffold a new Kywi CMS project — npx create-kywi-app my-site",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/Kywi-Software/kywi-cms#readme",