create-kywi-app 0.6.2 → 0.6.4

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,138 @@ 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`, `slug`, `relationship` and `multiSelect` fall back to
312
+ a plain text input, so name the allowed values in the label. A `json` prop is a
313
+ raw JSON textarea — developer territory. A "three stats" module with `stats: {
314
+ type: 'json' }` has taken the copy away from the owner; the same module with
315
+ `stat1Value` / `stat1Label` … as `text` props hasn't.
316
+
317
+ **Declare the primary copy prop first.** The front-of-site editor makes a
318
+ module's *first* prop inline-editable on the page when its type is `text`,
319
+ `textarea` or `richText` — so `props: { headline, … }` can be typed over
320
+ directly on the live page, and `props: { variant, headline, … }` cannot.
321
+
322
+ Also: create **saved layouts** (Layouts admin) as page templates — "Landing
323
+ page", "Case study" — so new pages start from a consistent skeleton. Hand-written
324
+ JSX stays fine for genuinely fixed chrome (a bespoke 404, legal boilerplate) —
325
+ but if marketing will ever want to swap a headline, it's a layout page.
165
326
 
166
327
  ## 6. Forms: always the Forms builder
167
328
 
@@ -209,6 +370,48 @@ default variant must stand alone (personalization is progressive enhancement);
209
370
  one experiment per conversion goal, and let it conclude before layering more.
210
371
  If no, skip it — the machinery is there when they grow into it.
211
372
 
373
+ ### Variant containers: pick the right shape
374
+
375
+ Two different things are called `variantContainer`, and they are not
376
+ interchangeable.
377
+
378
+ - **Section-level** — a *region* node, sibling of sections (`defaultSections` +
379
+ `variants[].sections`). Resolved **on the server** from the visitor's audience
380
+ or experiment arm, so the first paint is already correct: no flash, and the
381
+ page is complete with JavaScript off. This is the default for anything the
382
+ server can know — rule-based audiences (UTM, referrer, the `kywi_audience`
383
+ pin) and A/B experiments. Since 0.6.3 it also renders in the layout editor as
384
+ a badged block with an **arm switcher**, each arm editable with the ordinary
385
+ section/column/module tools, so server-resolution no longer costs the owner
386
+ their editing surface.
387
+ - **Module-level** — the `variantContainer` *module*, placed in a column; its
388
+ arms are HTML strings (`defaultContent`, `variants: [{audienceId, label,
389
+ content}]`). Every arm ships in the HTML (default visible, the rest
390
+ `display:none`) and the browser runtime reveals the matching one. Use it
391
+ **only for signals the server cannot read at first paint** — in practice, a
392
+ self-ID answer held in `localStorage`.
393
+
394
+ Four traps, each of which fails silently:
395
+
396
+ 1. **Use the layout module, not the React component.** `VariantContainer.tsx`
397
+ (`core/src/components/personalization/`) has a loading branch that renders
398
+ the skeleton and nothing else — a JS-off visitor gets an empty container —
399
+ and its `ab_test` path draws a fresh `Math.random()` per render, so the same
400
+ visitor sees a different arm on every refresh. The layout module
401
+ (`VariantContainerModule`) always renders the default arm.
402
+ 2. **Leave `skeleton` off.** With `skeleton: true` the container ships
403
+ `aria-busy="true"` and only the client runtime clears it — a JS-off visitor
404
+ sits in a loading state forever.
405
+ 3. **An audience match beats the experiment.** `resolveVariant` returns the
406
+ audience arm first, so a visitor who matches an audience never enters the
407
+ split. Don't run an experiment and an audience variant on the same container.
408
+ 4. **The client runtime needs `/kywi.js`.** Self-ID, behavioral re-evaluation
409
+ and the transparency badge no-op silently unless the built
410
+ `@kywi-software/js` bundle is at `public/kywi.js`.
411
+
412
+ Standing rule: **the default arm must be complete on its own.** It is what
413
+ search engines, answer engines, and every opted-out visitor receive.
414
+
212
415
  ## 9. Editorial workflow: drafts, review, versions
213
416
 
214
417
  For any site with more than one author — or an owner who wants a safety net —
@@ -288,6 +491,8 @@ early, so ask at scoping time.
288
491
  - Marketing imagery in `/public`.
289
492
  - A "blog" that is a folder of `.mdx` files the owner can't edit.
290
493
  - One-off content types created in config for shapes the owner will evolve.
494
+ - A page built as a flat stack of full-width `heading` + `richText` modules.
495
+ - A custom module whose owner-facing copy lives in a `json` prop.
291
496
  - Personalization built speculatively, with no owner request behind it.
292
497
  - Direct-publish-only workflow on a multi-author site.
293
498
 
@@ -0,0 +1,172 @@
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` / `slug` / `relationship` | plain text input (today) | constrained values — name the options in the label |
57
+ | `json` | raw JSON textarea | **developer territory** — structure the owner never edits |
58
+
59
+ Two rules that decide whether the module is really editable:
60
+
61
+ - **Keep owner copy out of `json`.** `stats: {type:'json'}` hands the owner a
62
+ JSON blob; `stat1Value` / `stat1Label` / `stat2Value` … as `text` props hands
63
+ them fields. Use `json` only for structure that never changes after build.
64
+ - **Declare the primary copy prop first.** The front-of-site editor makes a
65
+ module's *first* prop inline-editable on the live page when its type is
66
+ `text`, `textarea` or `richText`. Put the headline first; put `variant` and
67
+ layout switches after it.
68
+
69
+ ### 3. Register it — `defineModule` in `kywi.config.ts`
70
+
71
+ ```ts
72
+ import { defineKywiConfig, defineModule } from '@kywi-software/core/config'
73
+
74
+ export default defineKywiConfig({
75
+ // …sites, themes, contentTypes…
76
+ modules: [
77
+ defineModule({
78
+ name: 'splitFeature', // the layout node `type` + MCP add_module type
79
+ label: 'Media / text split', // what the owner sees in the palette
80
+ component: 'splitFeature', // the key you'll map in lib/modules.tsx
81
+ category: 'Marketing', // groups it in the palette
82
+ props: {
83
+ heading: { type: 'text', label: 'Heading' }, // first = inline-editable
84
+ body: { type: 'richText', label: 'Body' },
85
+ image: { type: 'image', label: 'Image' },
86
+ imageAlt: { type: 'text', label: 'Image alt text' },
87
+ reverse: { type: 'boolean', label: 'Image on the left', defaultValue: false },
88
+ },
89
+ }),
90
+ ],
91
+ })
92
+ ```
93
+
94
+ ### 4. Write the component
95
+
96
+ ```tsx
97
+ // lib/split-feature.tsx
98
+ 'use client'
99
+ import { resolveMediaRef } from '@kywi-software/core/layout'
100
+
101
+ export function SplitFeature({ props }: { props: Record<string, unknown> }) {
102
+ const src = resolveMediaRef(props.image) // media UUID → public file URL
103
+ return (
104
+ <div className={`split-feature${props.reverse === true ? ' is-reversed' : ''}`}>
105
+ <div className="split-feature__copy">
106
+ <h2>{String(props.heading ?? '')}</h2>
107
+ <div dangerouslySetInnerHTML={{ __html: String(props.body ?? '') }} />
108
+ </div>
109
+ {src ? <img src={src} alt={String(props.imageAlt ?? '')} /> : null}
110
+ </div>
111
+ )
112
+ }
113
+ ```
114
+
115
+ Every prop is `unknown` — coerce and default each one. A module that throws on a
116
+ missing prop breaks the editor canvas as well as the page.
117
+
118
+ ### 5. Map it in `lib/modules.tsx`
119
+
120
+ ```tsx
121
+ import { SplitFeature } from './split-feature'
122
+
123
+ export const moduleComponents: ModuleComponentMap = {
124
+ splitFeature: SplitFeature,
125
+ }
126
+ ```
127
+
128
+ This one map is passed to both `KywiAdminApp` and `KywiLayout`, so the module
129
+ renders identically in the editor canvas and live. **Skip this step and the
130
+ module renders "Unknown module" in both** — the most common custom-module bug.
131
+ The host merges `{ ...BUILT_IN_MODULE_COMPONENTS, ...moduleComponents }`, so an
132
+ entry named after a built-in replaces it.
133
+
134
+ ### 6. Hazards checklist
135
+
136
+ - **Unique ids need `React.useId()`.** A module-scope counter produces different
137
+ ids on server and client and breaks hydration. Same for `Date.now()` and
138
+ `Math.random()` in render.
139
+ - **Escape owner text inside `<script type="application/ld+json">`.** Replace
140
+ every `<` with the JS escape `\u003c`, break up `-->`, and escape the line
141
+ separators U+2028/U+2029 (matched via
142
+ `String.fromCharCode(0x2028, 0x2029)`) — otherwise a stray character in owner
143
+ copy ends the script tag early.
144
+ - **`dangerouslySetInnerHTML` is the pattern for `richText`/HTML props** — the
145
+ values are sanitized by Kywi before storage and on render. Don't reach for it
146
+ for plain `text` props.
147
+ - **Stay safe with JavaScript off.** A `'use client'` module still server-renders
148
+ its initial HTML — but anything that only appears after an effect is invisible
149
+ to crawlers and JS-off visitors. Interactivity is progressive enhancement over
150
+ complete server-rendered markup.
151
+ - **Style through theme tokens** (`var(--kywi-color-primary)`, `--kywi-spacing-*`)
152
+ in the site stylesheet, not hardcoded hex — the owner retunes the theme from
153
+ `kywi.config.ts` and your module should follow.
154
+ - **Don't fetch in the module.** Data comes from props (or, for feeds, the
155
+ host's server-side hydration). A module that fetches breaks the editor canvas.
156
+
157
+ ### 7. Verify
158
+
159
+ 1. Restart the dev server (config changes need a restart).
160
+ 2. Open the layout editor: the module appears in the palette under its
161
+ `category`, with its `label`.
162
+ 3. Place it, fill every prop from the props panel, save — it renders in the
163
+ canvas (not "Unknown module") and on the public page.
164
+ 4. Open the public page as a signed-in admin → **Edit this page**: the first
165
+ text prop is editable by typing on the page.
166
+ 5. Load the page with JavaScript disabled: the content is all there.
167
+
168
+ ### 8. Handover note
169
+
170
+ Record the module in `CONTENT-MODEL.md` (reusable components / modules) with one
171
+ line per prop: what the owner changes and where. A module the owner doesn't know
172
+ 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
  }
@@ -755,20 +783,30 @@ export function clientRuntimeEnabled(): boolean {
755
783
 
756
784
  /** The self-ID widget config in the serializable shape the client runtime reads. */
757
785
  export interface PublicSelfIdWidget {
758
- fields: Array<{ id: string; label: string; type: 'select'; options?: string[]; required?: boolean }>
786
+ fields: Array<{
787
+ id: string
788
+ label: string
789
+ type: 'select'
790
+ options?: Array<{ value: string; label: string }>
791
+ required?: boolean
792
+ }>
759
793
  headline: string
760
794
  subheadline?: string
761
795
  submitLabel: string
762
796
  skipLabel?: string
763
- displayMode: 'modal' | 'inline' | 'slide-in'
797
+ displayMode: 'modal' | 'inline' | 'slide-in' | 'hello_bar_top' | 'hello_bar_bottom' | 'drawer'
764
798
  frequency: 'once' | 'session' | 'always'
765
799
  trigger: SelfIdTrigger
766
800
  }
767
801
 
802
+ // The admin's five display modes (SelfIdWidgetConfig['displayMode']) and the
803
+ // client runtime's (PublicSelfIdWidget['displayMode'], @kywi-software/js) are the
804
+ // same set — the runtime implements hello_bar_top/hello_bar_bottom/drawer as
805
+ // their own layouts, not folded into slide-in (kywi-cms#97) — so this is a
806
+ // passthrough. Kept as a named function (rather than assigning displayMode
807
+ // directly) so the two types stay checked against each other at compile time.
768
808
  function mapDisplayMode(mode: SelfIdWidgetConfig['displayMode']): PublicSelfIdWidget['displayMode'] {
769
- if (mode === 'modal') return 'modal'
770
- if (mode === 'inline') return 'inline'
771
- return 'slide-in' // hello bars + drawer both animate in from an edge
809
+ return mode
772
810
  }
773
811
 
774
812
  function mapFrequency(freq: SelfIdWidgetConfig['frequency']): PublicSelfIdWidget['frequency'] {
@@ -797,7 +835,10 @@ export async function resolveSelfIdWidget(runtime: KywiRuntime): Promise<PublicS
797
835
  id: f.id,
798
836
  label: f.label,
799
837
  type: 'select' as const,
800
- options: f.picklist.map((p) => p.value),
838
+ // {value, label} pairs, not bare values — the client runtime renders the
839
+ // label and submits the value (kywi-cms#96); dropping the label here is
840
+ // what made every option render as its raw value.
841
+ options: f.picklist,
801
842
  required: f.required,
802
843
  }))
803
844
  if (fields.length === 0) return null
@@ -921,10 +962,33 @@ async function fetchFreshAccessToken(origin: string, refreshToken: string): Prom
921
962
  }
922
963
  }
923
964
 
965
+ // Markdown content negotiation by URL suffix. Core serves markdown at
966
+ // \`/api/v1/ax/md/slug/<slug>\` and the AX layer enables it (ax.markdown), but the
967
+ // scaffold never wires the friendly \`/<path>.md\` URL the AX pitch (and the
968
+ // developer copy) names — so it 404s despite the feature being on. Rewrite any
969
+ // GET/HEAD for \`/<path>.md\` onto the core route so the named URL actually
970
+ // resolves. Nested paths keep their full slug (e.g. /docs/foo.md -> docs/foo).
971
+ // Never intercepts Next internals or the API — those never carry this suffix,
972
+ // but are excluded explicitly since the matcher's own exclusion is broad.
973
+ function handleMarkdownNegotiation(req: NextRequest): NextResponse | null {
974
+ const { pathname } = req.nextUrl
975
+ if (req.method !== 'GET' && req.method !== 'HEAD') return null
976
+ if (!pathname.endsWith('.md')) return null
977
+ if (pathname.startsWith('/api/') || pathname.startsWith('/_next/')) return null
978
+ const slug = pathname.replace(/^\\//, '').replace(/\\.md$/, '')
979
+ if (!slug) return null
980
+ const url = req.nextUrl.clone()
981
+ url.pathname = '/api/v1/ax/md/slug/' + slug
982
+ return NextResponse.rewrite(url)
983
+ }
984
+
924
985
  export async function middleware(req: NextRequest): Promise<NextResponse> {
925
986
  const { pathname } = req.nextUrl
926
987
  if (isAuthEndpoint(pathname)) return NextResponse.next()
927
988
 
989
+ const md = handleMarkdownNegotiation(req)
990
+ if (md) return md
991
+
928
992
  // Public pages are never auth-gated — just give them a stable visitor id.
929
993
  const isAdmin = pathname === '/admin' || pathname.startsWith('/admin/')
930
994
  const isApi = pathname.startsWith('/api/v1/')
@@ -980,6 +1044,10 @@ export const config = {
980
1044
  // any path with a file extension (static assets, /favicon.ico, /kywi.js, and
981
1045
  // the AX files /robots.txt, /sitemap.xml, /llms*.txt).
982
1046
  '/((?!_next/|.*\\\\..*).*)',
1047
+ // \`/<path>.md\` — the markdown-negotiation URL. The extension-excluding
1048
+ // pattern above skips it, so it needs its own entry to reach
1049
+ // handleMarkdownNegotiation.
1050
+ '/((?!_next/|api/).*\\\\.md)',
983
1051
  ],
984
1052
  }
985
1053
  `
@@ -1239,12 +1307,17 @@ function hasRenderableLayout(layout: LayoutDocument | null | undefined): layout
1239
1307
  // Is the current visitor a signed-in admin who may edit? The public route is
1240
1308
  // outside the middleware's auth matcher, so verify the httpOnly session cookie
1241
1309
  // here and derive the content permissions the overlay needs. Returns null for
1242
- // anyone who cannot edit — no edit DOM is emitted for them.
1310
+ // anyone who cannot edit — no edit DOM (not even the browse-mode toolbar) is
1311
+ // emitted for them. Called on EVERY request (not just ?kywi-edit=1 ones) so the
1312
+ // toolbar can surface itself for a signed-in admin who is just browsing — this
1313
+ // is a cookie read + JWT verify, no DB round-trip, so the cost on an anonymous
1314
+ // visitor's fast path is a fast null return (\`readSessionClaims\` bails
1315
+ // immediately when there's no cookie).
1243
1316
  async function resolveEditPermissions() {
1244
1317
  const token = (await cookies()).get(ACCESS_COOKIE)?.value
1245
1318
  const claims = await readSessionClaims(token, config.auth.secret)
1246
1319
  if (!claims || !canAccessContent(claims.role, 'write')) return null
1247
- return { canEdit: true, canPublish: canAccessContent(claims.role, 'publish') }
1320
+ return { canEdit: true, canPublish: canAccessContent(claims.role, 'publish'), role: claims.role }
1248
1321
  }
1249
1322
 
1250
1323
  // "/" resolves the seeded Home node; any other URL resolves the published node at
@@ -1264,9 +1337,13 @@ export default async function PublicPage({ params, searchParams }: Params & Sear
1264
1337
  const featured = mediaUrl(node['featuredImageId'])
1265
1338
  const layout = node['layout'] as LayoutDocument | null | undefined
1266
1339
 
1267
- // ?kywi-edit=1 opts an authenticated admin into the front-of-site overlay.
1340
+ // Resolved on every request so a signed-in admin gets the persistent browse
1341
+ // toolbar even when just browsing — ?kywi-edit=1 only decides whether the
1342
+ // page auto-enters the full overlay editor on mount (kywi-cms#93). An
1343
+ // anonymous/read-only visitor resolves to perms === null: zero extra client
1344
+ // JS, identical output to before this route ever heard of the overlay.
1268
1345
  const editRequested = (await searchParams)['kywi-edit'] === '1'
1269
- const perms = editRequested ? await resolveEditPermissions() : null
1346
+ const perms = await resolveEditPermissions()
1270
1347
 
1271
1348
  // Server-side personalization (#50), evaluated once: the winning audience (or a
1272
1349
  // kywi_preview_init preview) drives page variants + variantContainers, and this
@@ -1347,12 +1424,16 @@ export default async function PublicPage({ params, searchParams }: Params & Sear
1347
1424
  )
1348
1425
  }
1349
1426
 
1350
- // Mount the overlay (client) only for an authenticated admin who asked for it.
1427
+ // Mount the overlay (client) for any authenticated admin (write role+)
1428
+ // browse mode renders just the slim toolbar; ?kywi-edit=1 (editRequested)
1429
+ // additionally auto-starts the full overlay editor. Nothing mounts for an
1430
+ // anonymous or read-only visitor.
1351
1431
  if (perms) {
1352
1432
  return (
1353
1433
  <KywiFrontEdit
1354
1434
  canEdit={perms.canEdit}
1355
1435
  canPublish={perms.canPublish}
1436
+ editRequested={editRequested}
1356
1437
  contentId={contentId}
1357
1438
  contentType={contentType}
1358
1439
  pageTitle={title}
@@ -1372,18 +1453,25 @@ export default async function PublicPage({ params, searchParams }: Params & Sear
1372
1453
  }
1373
1454
 
1374
1455
  /**
1375
- * Front-of-site edit overlay (client). Mounted by the public page ONLY for an
1376
- * authenticated admin who opened the page with ?kywi-edit=1 (the page verifies
1377
- * the session cookie server-side first).
1456
+ * Front-of-site edit overlay (client). Mounted by the public page for ANY
1457
+ * authenticated admin (write role+) the page verifies the session cookie
1458
+ * server-side first (resolveEditPermissions, called unconditionally) and only
1459
+ * mounts this when that check passes, so canEdit is always true here.
1378
1460
  *
1379
1461
  * Two modes, same component:
1380
- * - **Browse** — core's slim `KywiEditToolbar` over the live page, with the
1381
- * editable regions outlined via the `data-kywi-*` protocol.
1382
- * - **Edit** (`?kywi-edit=1`, or the toolbar's Edit toggle) — the FULL
1383
- * `OverlayShell` layout editor, the same one the admin app mounts, rendered
1384
- * from the same core module registry + custom renderers. Save PUTs the layout
1385
- * document; Publish PUTs the layout then flips status — exactly the API the
1386
- * admin editor uses.
1462
+ * - **Browse** (default) — core's slim `KywiEditToolbar`, a persistent
1463
+ * WordPress-admin-bar-style strip over the live page (page title, draft/
1464
+ * published status, "Edit this page", "Go to admin") — this is the
1465
+ * discoverability fix (kywi-cms#93): an admin browsing the public site
1466
+ * normally, with no query param, now sees the entry point. The editable
1467
+ * regions are also outlined via the `data-kywi-*` protocol.
1468
+ * - **Edit** (the toolbar's "Edit this page", or landing with
1469
+ * `?kywi-edit=1`) — the FULL `OverlayShell` layout editor, the same one the
1470
+ * admin app mounts, rendered from the same core module registry + custom
1471
+ * renderers. Save PUTs the layout document; Publish PUTs the layout then
1472
+ * flips status — exactly the API the admin editor uses. Entering/leaving
1473
+ * edit mode keeps `?kywi-edit=1` in sync via history.replaceState, so a
1474
+ * reload (or a shared link) lands back in the same mode.
1387
1475
  *
1388
1476
  * The OverlayShell is lazy-loaded (`next/dynamic`, client-only): its weight
1389
1477
  * (dnd-kit, canvas, side panels) is only fetched when an editor actually enters
@@ -1420,6 +1508,8 @@ const OverlayShell = dynamic(
1420
1508
  export interface KywiFrontEditProps {
1421
1509
  canEdit: boolean
1422
1510
  canPublish: boolean
1511
+ /** True when the page was requested with \`?kywi-edit=1\` — auto-starts the full overlay editor on mount. */
1512
+ editRequested: boolean
1423
1513
  contentId: string
1424
1514
  contentType: string
1425
1515
  pageTitle: string
@@ -1434,13 +1524,19 @@ export interface KywiFrontEditProps {
1434
1524
  }
1435
1525
 
1436
1526
  /**
1437
- * Wraps the public page with the front-of-site edit affordance. The page only
1438
- * renders this for a signed-in admin who asked to edit (?kywi-edit=1), so the
1439
- * permission gate (canEdit) is always satisfied here.
1527
+ * Wraps the public page with the front-of-site edit affordance. The page
1528
+ * renders this for ANY signed-in admin (write role+) canEdit is always true
1529
+ * here, since the page only mounts KywiFrontEdit once resolveEditPermissions
1530
+ * has already confirmed it server-side. Browse mode shows the persistent
1531
+ * KywiEditToolbar (the primary discoverability fix, kywi-cms#93); the full
1532
+ * OverlayShell editor only mounts once edit mode actually starts — either the
1533
+ * toolbar's "Edit this page" button, or landing with \`?kywi-edit=1\`
1534
+ * (editRequested), which auto-starts it once on mount.
1440
1535
  */
1441
1536
  export function KywiFrontEdit({
1442
1537
  canEdit,
1443
1538
  canPublish,
1539
+ editRequested,
1444
1540
  contentId,
1445
1541
  contentType,
1446
1542
  pageTitle,
@@ -1452,11 +1548,14 @@ export function KywiFrontEdit({
1452
1548
  }: KywiFrontEditProps) {
1453
1549
  const edit = useKywiEditMode({ canEdit, canPublish })
1454
1550
 
1455
- // ?kywi-edit=1 means "enter edit mode now" — flip it on once after mount.
1551
+ // ?kywi-edit=1 (editRequested) means "enter edit mode now" — flip it on once
1552
+ // after mount. Without it the page mounts straight into browse mode (the
1553
+ // persistent toolbar), which is the common case now that KywiFrontEdit
1554
+ // renders for every signed-in admin, not only deep-linked ones.
1456
1555
  const { startEdit, endEdit } = edit
1457
1556
  React.useEffect(() => {
1458
- startEdit()
1459
- }, [startEdit])
1557
+ if (editRequested) startEdit()
1558
+ }, [editRequested, startEdit])
1460
1559
 
1461
1560
  // Registries + renderers for the editor: built from core (no module list is
1462
1561
  // re-declared here) and merged with this app's custom module renderers from
@@ -1514,15 +1613,23 @@ export function KywiFrontEdit({
1514
1613
  }, [contentId])
1515
1614
 
1516
1615
  // Edit mode: the full layout editor, in place, over the live page.
1616
+ // NOTE: no \`kywi-admin-shell\` here (kywi-cms#94). That class is the admin
1617
+ // design system's base+reset — font family, font size, colours, heading
1618
+ // resets — and wrapping the page in it re-typesets the very content the
1619
+ // owner is trying to judge at real width. The editor chrome carries its own
1620
+ // styling; the page keeps the site's.
1517
1621
  if (edit.isEditMode && edit.canEdit) {
1518
1622
  return (
1519
- <div className="kywi-admin-shell kywi-frontend-edit">
1623
+ <div className="kywi-frontend-edit">
1520
1624
  <OverlayShell
1521
1625
  editMode={edit}
1522
1626
  initialLayout={initialLayout}
1523
1627
  contentId={contentId}
1524
1628
  contentType={contentType}
1525
1629
  pageTitle={pageTitle}
1630
+ /* The page's own body wrapper, so the site's page-level CSS (width,
1631
+ gutters, rhythm) still applies while editing in place. */
1632
+ pageClassName="page page--layout"
1526
1633
  themeName="default"
1527
1634
  themeRegistry={themeRegistry}
1528
1635
  moduleRegistry={moduleRegistry}
@@ -1969,14 +2076,21 @@ wired in \`app/(site)/[[...slug]]/page.tsx\`). Pages without a layout fall back
1969
2076
  their Body rich text. Reach for the Layout tab when a page needs sections,
1970
2077
  columns, or modules; use the Body for simple prose.
1971
2078
 
1972
- **Front-of-site editor.** Signed in as an admin, append \`?kywi-edit=1\` to any
1973
- public page to edit it in place. You get the full **Layout editor** (the same
1974
- drag-and-drop canvas, module palette and props panel as the admin's Layout tab),
1975
- mounted right over the live page add sections and modules, then **Save** (PUTs
1976
- the layout) or **Publish** (saves + publishes). Exit the editor for the slim
1977
- browse toolbar with the editable-region outlines. It all lives in
2079
+ **Front-of-site editor.** Signed in as an admin, every public page shows a slim
2080
+ toolbar across the top the page title, its draft/published status, **Edit
2081
+ this page**, and **Go to admin** (a WordPress-admin-bar equivalent, and the
2082
+ primary way to discover in-place editing; there's also an **Edit on site**
2083
+ link on each item in the admin content editor, for the reverse direction).
2084
+ Click **Edit this page** — or open a page with \`?kywi-edit=1\` as a deep link
2085
+ to get the full **Layout editor** (the same drag-and-drop canvas, module
2086
+ palette and props panel as the admin's Layout tab) mounted right over the live
2087
+ page: add sections and modules, then **Save** (PUTs the layout) or **Publish**
2088
+ (saves + publishes). **Done** returns you to the browse toolbar. Entering or
2089
+ leaving the editor keeps \`?kywi-edit=1\` in the URL in sync, so reloading (or
2090
+ sharing the link) lands back in the same mode. It all lives in
1978
2091
  \`app/(site)/kywi-front-edit.tsx\`; the editor bundle (and the admin stylesheet) is
1979
- lazy-loaded, so pages your visitors see never carry its weight. Note: custom
2092
+ lazy-loaded, so pages your visitors see never carry its weight an anonymous
2093
+ or read-only visitor gets no toolbar and no extra client JS. Note: custom
1980
2094
  \`defineModule\` types still RENDER on the canvas, but they don't appear in the
1981
2095
  front-of-site editor's insert palette (it uses the built-in module set) — add
1982
2096
  them from the admin's Layout tab instead.
@@ -2064,7 +2178,10 @@ in \`kywi.config.ts\` (\`modules: [defineModule({ name: 'pricingTable', … })]\
2064
2178
  then map its \`name\` to the React component that renders it in \`lib/modules.tsx\`.
2065
2179
  That one map is passed to both the admin editor and the public site, so a custom
2066
2180
  module renders identically in the canvas and live (it shows an "Unknown module"
2067
- 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.
2068
2185
 
2069
2186
  ### Create your first page
2070
2187
 
@@ -2085,12 +2202,10 @@ ${themingBlock}
2085
2202
  ## Project layout
2086
2203
 
2087
2204
  \`\`\`
2088
- 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
2089
2206
  AGENTS.md guidance for AI agents working on this site (Kywi's building patterns)
2090
2207
  CLAUDE.md points AI agents to AGENTS.md
2091
- .claude/skills/kywi-content-model/SKILL.md content-model skill (loaded automatically)
2092
- .claude/skills/kywi-collections/SKILL.md collections skill (loaded automatically)
2093
- .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')}
2094
2209
  middleware.ts auth gate + session refresh + cookie→bearer bridge
2095
2210
  next.config.mjs required Next config to consume @kywi-software/core
2096
2211
  lib/kywi.ts server runtime (DB, API handler, content scope)
@@ -2137,6 +2252,38 @@ function agentPatternsDoc() {
2137
2252
  return _agentPatternsDoc
2138
2253
  }
2139
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
+
2140
2287
  /**
2141
2288
  * Kywi's Claude Code project skills — each ships as a package asset and is
2142
2289
  * embedded verbatim into every generated app's .claude/skills/<slug>/SKILL.md
@@ -2149,6 +2296,9 @@ export const SKILLS = [
2149
2296
  { slug: 'kywi-content-model', asset: 'kywi-content-model-skill.md' },
2150
2297
  // When adding any collection: folder + feed + Feed Display, end to end.
2151
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' },
2152
2302
  // When the owner wants personalization/A-B: confirm the use case, then build.
2153
2303
  { slug: 'kywi-personalization', asset: 'kywi-personalization-skill.md' },
2154
2304
  ]
@@ -2320,10 +2470,13 @@ export function agentsMd(a, landmarks = scaffoldLandmarks(a.mode)) {
2320
2470
  skill, loaded automatically before building anything.`)
2321
2471
  bullets.push(`- \`.claude/skills/kywi-collections/SKILL.md\` — collections skill, loaded
2322
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.`)
2323
2475
  bullets.push(`- \`.claude/skills/kywi-personalization/SKILL.md\` — personalization skill,
2324
2476
  loaded automatically when the owner wants personalization or A/B testing.`)
2325
2477
 
2326
- const header = `# Agent guide — ${a.projectName}
2478
+ const header = `${guidanceStamp()}
2479
+ # Agent guide — ${a.projectName}
2327
2480
 
2328
2481
  ${intro}
2329
2482
  ${modeSentence}
@@ -2365,7 +2518,7 @@ holds Kywi's official building patterns and a map of where things live in this a
2365
2518
 
2366
2519
  Core principle: **model content in the CMS instead of hardcoding it** — see \`AGENTS.md\`.
2367
2520
 
2368
- 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.
2369
2522
  `
2370
2523
  }
2371
2524
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-kywi-app",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
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",