create-kywi-app 0.14.0 → 0.15.1

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.
@@ -185,16 +185,36 @@ every request — so writing a new definition changes every page that uses it
185
185
  immediately, published pages included, with nothing to re-publish. That is the
186
186
  whole point, and it is also the thing to be careful with.
187
187
 
188
- **What can be one.** A single module, a whole section (band), or a personalized /
189
- A-B tested band with its variants intact the three `type` values are `module`,
190
- `section` and `variantContainer`. A band's variants travel with it; which arm a
191
- visitor sees is still decided per request, per placement, so the same personalized
192
- component can sit on five pages and target correctly on each.
188
+ **What can be one.** A single module, a whole section (band), a personalized /
189
+ A-B tested band with its variants intact, or a personalized / A-B tested **module**
190
+ the four `type` values are `module`, `section`, `variantContainer` (section
191
+ level) and `moduleVariantContainer` (column level). Variants travel with the
192
+ component in both container kinds; which arm a visitor sees is still decided per
193
+ request, per placement, so the same personalized component can sit on five pages
194
+ and target correctly on each.
195
+
196
+ **Which container kind?** `variantContainer` is a whole band that varies —
197
+ different sections, different layouts per audience. `moduleVariantContainer` is
198
+ ONE module in a column that varies while everything around it stays put: a hero
199
+ headline that changes for paid traffic, a CTA button that is A/B tested inside an
200
+ otherwise fixed section. Reach for the module-level one when only the content of
201
+ a single block differs; personalizing the whole band to change one headline makes
202
+ every other module in it a copy the owner has to maintain twice.
203
+
204
+ **Arms are never independently linkable.** A variant arm's module cannot be saved
205
+ as a component of its own, and cannot be pointed at one. An arm is a challenger
206
+ the owner edits freely; wiring it to shared content would mean editing one page's
207
+ variant rewrote every other page's, which is the opposite of what a variant is
208
+ for. Save the whole container instead — that is what the fourth kind is.
193
209
 
194
210
  **In the editors** (front-of-site overlay or the admin Layout tab): select the
195
- module, section or band → **Save as component** → name it. The selection is
196
- replaced in place by a linked instance, marked with an outline and a pill naming
197
- the component. Place it again from the section picker's *Components* rows.
211
+ module, section, band or personalized module → **Save as component** → name it.
212
+ The selection is replaced in place by a linked instance, marked with an outline
213
+ and a pill naming the component. Place a section or band again from the section
214
+ picker's *Components* rows; place a module or a personalized module by dropping
215
+ the palette's **Component** block into a column and choosing it in the props
216
+ rail — one list, both kinds, because the owner's question there is "which saved
217
+ thing goes here".
198
218
 
199
219
  **Editing a linked instance ends in a question.** There are no per-prop overrides
200
220
  in v1: an instance renders its component exactly. So when you edit one and save,
@@ -224,8 +244,10 @@ per-site authorization applies.
224
244
 
225
245
  1. `POST /api/v1/components` — `{ name, type, definition, moduleType?, description? }`.
226
246
  The `definition` is the node itself: a `section` component stores the whole
227
- `LayoutSection`, a `variantContainer` the whole container, a `module` component
228
- stores `{ props, style }` and names its `moduleType`.
247
+ `LayoutSection`, a `variantContainer` the whole section-level container, a
248
+ `moduleVariantContainer` the whole column-level one (its `defaultModule` and
249
+ `variants`), and a `module` component stores `{ props, style }` and names its
250
+ `moduleType`.
229
251
  2. **Place it** by writing a layout that carries the link. There is no separate
230
252
  "insert" endpoint: `PUT /api/v1/content/:type/:id/layout` with the definition's
231
253
  own shape plus `componentId` on the node, giving the placement a fresh `id` of
@@ -247,8 +269,26 @@ per-site authorization applies.
247
269
  definition saved from an editor carries no `winnerId` at all; a definition
248
270
  POSTed straight over the API can, so read what you are placing. A
249
271
  `personalization` container has no binding and needs none.
272
+
273
+ **The same rule, word for word, for `moduleVariantContainer`.** It is a
274
+ placement root exactly as its section-level sibling is: the placement's
275
+ `experimentId` / `winnerId` win over the definition's, so an `ab_test`
276
+ placement you write over the API must carry a fresh `experimentId` and no
277
+ `winnerId`. The node goes in a COLUMN (`section.columns[].nodes[]`), not in a
278
+ region, and it carries its own `id` plus `componentId`, with the definition's
279
+ `defaultModule` / `variants` copied inline as the fallback copy.
280
+
281
+ **Do not bury an `ab_test` module container inside a `section` component.**
282
+ There it is not a placement root, nothing re-binds it per placement, and every
283
+ page placing that section reports into one shared run with one `winnerId`
284
+ deciding them all. Both editors refuse it and so does `POST /components`. A
285
+ `personalization` module container inside a section component is fine —
286
+ audiences are content and travel with the component.
250
287
  3. `GET /api/v1/components/:id/usage` — where it is placed (`instances`, `pages`,
251
- the page ids and node ids, and trashed pages counted separately). This is the
288
+ the page ids and node ids, with trashed pages and **saved layouts** each
289
+ counted separately: `trashedInstances`/`trashedPages` and
290
+ `blueprints`/`blueprintInstances`/`blueprintItems`). `instances`/`pages` mean
291
+ live pages only, so the three partitions never double-count. This is the
252
292
  blast radius; read it *before* changing a definition.
253
293
  4. `PUT /api/v1/components/:id` — a body with `definition` **is the propagation
254
294
  write**: every linked instance renders the new definition on its next request.
@@ -257,15 +297,20 @@ per-site authorization applies.
257
297
  5. `POST /api/v1/components/:id/detach-all` — materialize the definition into every
258
298
  page that links it and drop the links; each page keeps rendering identically.
259
299
  6. `DELETE /api/v1/components/:id` — refuses (409) while instances exist; add
260
- `?detachAll=1` to detach-then-delete. `GET /api/v1/components?withUsage=1` folds
300
+ `?detachAll=1` to detach-then-delete. "Instances" includes placements held by
301
+ trashed pages **and by saved layouts** (kywi-cms#156), so a component nothing
302
+ live uses can still refuse to delete — check `blueprints` in the usage payload
303
+ before assuming a one-step delete. `GET /api/v1/components?withUsage=1` folds
261
304
  the counts into a list, in one read.
262
305
 
263
- **Two refusals to design around.** A component definition may not link to another
306
+ **Three refusals to design around.** A component definition may not link to another
264
307
  component (one level only, deliberately: a nested link is invisible to usage counts
265
308
  and the delete gate, so the editors *materialize* an inner component when you save
266
309
  a section that contains one — the inner instance stops following its own source, and
267
310
  the dialog says so). And a stored layout that a live layout could not hold is
268
311
  rejected at the door, because detaching would copy it into every page that links it.
312
+ And a `section` or `variantContainer` definition may not contain an **A/B tested**
313
+ module container, for the shared-run reason above.
269
314
 
270
315
  **Component writes bypass approvals in v1.** They are not routed through
271
316
  changesets or the review workflow — a `PUT` with a definition is live on publish
@@ -286,6 +331,32 @@ reads as a styled document rather than a site. Sections are bands (each owns its
286
331
  background and padding), `columns` splits them, and the object modules below do
287
332
  the rest.
288
333
 
334
+ ### Module props: omitted takes the default, `''` stays blank
335
+
336
+ Every module declares its props, and most declare a `defaultValue` — the copy or
337
+ setting the module ships with so it renders as *something* the moment it is
338
+ placed. That default is applied on the **write path**, so it applies however the
339
+ module got there: dragged in by the editor, added with MCP `add_module`, or
340
+ written straight into a layout document.
341
+
342
+ The rule has two halves, and the second is the one that matters:
343
+
344
+ - **A prop you OMIT takes the module's declared default.** You do not need to
345
+ restate a module's own defaults to make it look right, and a custom module
346
+ does not need a second copy of them baked into its component.
347
+ - **A prop you write as `''` stays `''`.** An empty string is an instruction —
348
+ "blank on purpose" — not an absent value. Same for `null`, `0` and `false`:
349
+ presence of the key is the whole signal.
350
+
351
+ So `add_module` with no `props` gives you a module that renders; `add_module`
352
+ with `props: { eyebrow: '' }` gives you one with a deliberately empty eyebrow
353
+ and every other prop defaulted. To find out what a module's defaults actually
354
+ are, call `list_module_types` — each prop reports its `default`.
355
+
356
+ Defaults are seeded onto modules a write **adds**, never re-applied to modules
357
+ already on the page. Saving a page for an unrelated reason will not put back a
358
+ prop that someone deliberately left off an existing module.
359
+
289
360
  ### The built-in palette
290
361
 
291
362
  `name` is the exact `type` value in a layout node and in MCP `add_module`. A
@@ -360,11 +431,66 @@ authored elsewhere in the admin).
360
431
  | `personalizationBadge` | Optional inline status pill that opens the global transparency panel — not needed on ordinary pages (§8) |
361
432
  | `socialShare` | Share links for a URL/title across networks |
362
433
  | `map` | Embedded map at a lat/lng or address |
363
- | `cookieConsent` | Consent banner with message + privacy-policy link |
434
+ | `cookieConsent` | Consent banner with message + privacy-policy link. Accept All / Reject All / Customize write the `kywi_consent` cookie, and the engine's optional cookie writes are gated on it (see below) |
364
435
  | `component` | Places a saved reusable component by slug (§4) |
365
436
  | `form` | Renders a form built in the Forms admin (§6) |
366
437
  | `formEmbed` | Embeds a form by slug — lighter-weight placement of the same |
367
438
 
439
+ #### Cookie consent is enforced, not decorative
440
+
441
+ `cookieConsent` is wired to the engine. Its buttons write `kywi_consent`, and
442
+ `@kywi-software/core`'s `audiences/consent.ts` holds the one map from cookie name
443
+ to category that every optional write is gated on:
444
+
445
+ | Cookie | Category | Gated? |
446
+ |---|---|---|
447
+ | `kywi_visitor`, `kywi_signals`, `kywi_utm`, `kywi_audience`, `kywi_known` | `personalization` | yes |
448
+ | `kywi_consent`, `kywi_optout`, `kywi_preview_init` | `essential` | no — necessary cookies are never gated |
449
+
450
+ A gated cookie is neither **written** nor **read** without consent: a visitor who
451
+ accepted last month and rejects today has those cookies deleted, and any that
452
+ survive (`kywi_signals` is HttpOnly) are ignored on the way in. Rejecting is a
453
+ revocation, not just a pause.
454
+
455
+ One exemption: an audience the visitor picks **by hand** in the transparency
456
+ panel is stored as an `essential` preference, on the same argument as
457
+ `kywi_optout` — a control they operated deliberately has to be honoured, or the
458
+ panel is lying about what its buttons do. Only the runtime's automatic
459
+ "remember what I resolved" write is gated. The cookie records which it was
460
+ (`switcher:<id>` vs a bare id), so the read side and the revocation sweep apply
461
+ the same rule: rejecting personalization deletes an automatic pin but keeps one
462
+ the visitor chose.
463
+
464
+ **Undecided means not consented.** Until a visitor answers the banner, none of
465
+ the personalization cookies are written: pages still render, audiences still
466
+ resolve for the request in hand, experiments still return a variant — nothing is
467
+ persisted, so the visitor keeps seeing default content instead of being tracked.
468
+ A site that wants personalization to persist must place this module (or write
469
+ `kywi_consent` some other way). Adding a new cookie without classifying it in
470
+ that map fails closed, and a core test catches the omission.
471
+
472
+ **The escape hatch.** `theme.personalization.requireConsent` in `kywi.config.ts`
473
+ defaults to `true` — the behaviour above. Set it to `false` only when this site
474
+ collects consent with EXTERNAL tooling (a third-party CMP) that suppresses
475
+ cookies its own way; the gate then treats every category as allowed and the site
476
+ takes responsibility for compliance. Because Next.js edge middleware cannot read
477
+ `kywi.config.ts`, mirror the same value as `KYWI_REQUIRE_CONSENT=false` in the
478
+ environment. **Set both or neither** — `createKywiApiHandler` throws at startup
479
+ when they disagree, so a mismatch fails the deploy rather than silently leaving
480
+ the middleware and the API at odds about whether a visitor may be tracked. On a
481
+ site with no other consent mechanism, `false` is simply the non-compliant
482
+ setting.
483
+
484
+ The banner ships **hidden** (`kywi-consent-pending` on the wrapper) and reveals
485
+ itself after mount only for a visitor with no decision on file — a theme must not
486
+ force it visible, or every returning visitor gets a flash of a consent ask they
487
+ already answered.
488
+
489
+ When consent is required and no page places the module, the API logs a one-time
490
+ warning on its first request: nobody can grant consent, so every personalization
491
+ cookie stays suppressed and audiences, A/B bucketing and UTM attribution serve
492
+ default content to everyone.
493
+
368
494
  ### Which do I reach for? (in order)
369
495
 
370
496
  1. **A built-in already does it** → use it. Read the tables before inventing
@@ -409,9 +535,12 @@ module's *first* prop inline-editable on the page when its type is `text`,
409
535
  directly on the live page, and `props: { variant, headline, … }` cannot.
410
536
 
411
537
  Also: create **saved layouts** (Layouts admin) as page templates — "Landing
412
- page", "Case study" — so new pages start from a consistent skeleton. Hand-written
413
- JSX stays fine for genuinely fixed chrome (a bespoke 404, legal boilerplate)
414
- but if marketing will ever want to swap a headline, it's a layout page.
538
+ page", "Case study" — so new pages start from a consistent skeleton. A saved
539
+ layout may link reusable components like any page: the link is indexed, counted
540
+ in the component's usage under "saved layouts", and carried into every page
541
+ stamped out of that layout. Hand-written JSX stays fine for genuinely fixed
542
+ chrome (a bespoke 404, legal boilerplate) — but if marketing will ever want to
543
+ swap a headline, it's a layout page.
415
544
 
416
545
  ### Navigation: menus vs. the site tree
417
546
 
@@ -550,6 +679,27 @@ interchangeable. Two of them are current.
550
679
  forced — so a page that already has one is not broken, it is simply not the
551
680
  shape to add more of.
552
681
 
682
+ **Two signals make campaign handoffs and site-owned self-ID cheap
683
+ (kywi-cms#196/#200).** `query.<param>` matches the query string of the request
684
+ being served — `/developers?from=marketer` is the rule `query.from equals
685
+ marketer`, matched on that very request with no reload — and `session.entryPage`
686
+ is the session's first page (path + query), so the rule survives the visitor
687
+ navigating on. For an explicit control instead of the built-in self-ID widget,
688
+ `Kywi.setSelfId({ role: 'marketer' })` records the answer, resolves and PINS the
689
+ audience server-side and reloads; the pin is the part that matters, because the
690
+ losing arms are pruned on the server (see #167 below) and only a fresh server
691
+ render can show them. A plain form post to `/api/v1/kywi/self-id` with a
692
+ `returnTo` field does the same with JavaScript off. Two rules that decide
693
+ whether it works at all: **the endpoint stores only self-ID fields the site
694
+ declared** (Audiences → Self-ID Fields), so an undeclared key is dropped; and
695
+ **an audience with no rules never matches from answers**, because resolution
696
+ runs the same rules everything else does. For a chooser whose options ARE
697
+ audiences, flag them **public**, read the options from
698
+ `GET /api/v1/kywi/audiences` (which publishes only `{ id, name, label }`), and
699
+ pass the id straight back — `Kywi.setSelfId({}, { audienceId })`, or a hidden
700
+ `audienceId` input on the form — which pins the visitor's pick directly, rules
701
+ or no rules.
702
+
553
703
  **Neither container ever nests.** Not one inside the other, not one inside
554
704
  itself. A module already sitting in a personalized section's arm cannot be
555
705
  promoted — the editor withholds the action, `personalize_module` returns a
@@ -582,6 +732,39 @@ Four traps, each of which fails silently:
582
732
  Standing rule: **the default arm must be complete on its own.** It is what
583
733
  search engines, answer engines, and every opted-out visitor receive.
584
734
 
735
+ **What actually reaches the visitor (kywi-cms#167).** A published page ships
736
+ only the arm that visitor is served — the other arms of every section- and
737
+ module-level container are stripped on the server, before the document crosses
738
+ into the client renderer (`pruneLayoutToServedArms`, run last in the public
739
+ render path). So view-source shows one arm, not all of them, and the same is
740
+ true of page variants (`applyPageVariant` has always dropped those). Two things
741
+ follow. First, arm copy is not shared with the people it is not for, so an
742
+ unlaunched offer or a segment-specific price is not sitting in the HTML of
743
+ everyone else's page. Second, **the audience RULES still are** — the client
744
+ runtime is handed the active audiences so it can re-evaluate on client signals,
745
+ so how a visitor is classified remains public even though the content each class
746
+ gets is not. Author rules accordingly: they are readable.
747
+
748
+ **One exception, and it is a live one: the retired `variantContainer` MODULE.**
749
+ Pruning covers the two container NODES. It cannot cover the legacy module,
750
+ because that module's arms are not arms of a container at all — they are HTML
751
+ strings in its own props, and it renders every one of them into the DOM by
752
+ design, hiding the losing ones with `display: none` for the client runtime to
753
+ reveal. So a page that still carries one keeps publishing all of its arms in
754
+ plain markup, where `view-source` and "inspect element" both find them
755
+ immediately. Nothing about #167 changed that, and no amount of server-side
756
+ resolution can: the shape is client-swap by construction. If a page you are
757
+ working on has one, converting it is the fix — `kywi upgrade:variant-containers`
758
+ (landing separately, for kywi-cms#172) rewrites existing placements into the
759
+ `moduleVariantContainer` node, which is server-resolved and therefore pruned.
760
+ Until it is converted, treat that module's arms as public, whatever the audience
761
+ binding says.
762
+
763
+ The editor is the deliberate exception — the front-edit overlay and the admin
764
+ layout editor are handed the STORED document, every arm intact, because that is
765
+ what is being edited. If you are writing a host page, keep the two apart: prune
766
+ what you pass to `<KywiLayout>`, and pass the editor the layout you loaded.
767
+
585
768
  ### Transparency: automatic, not something you place
586
769
 
587
770
  Once the client runtime is loaded, every personalized (or opted-out-with-a-
@@ -34,6 +34,36 @@ Admin → Audiences: define the rules, or configure the self-ID widget and its
34
34
  fields. Use the audience test tool to confirm the rules match the intended
35
35
  visitors before wiring anything to it.
36
36
 
37
+ ### 3b. Two shortcuts worth knowing before you build rules
38
+
39
+ - **A campaign link is a one-line rule.** `query.<param>` matches the query
40
+ string of the request being served, so `/developers?from=marketer` is
41
+ `query.from equals marketer` — and it matches on that very request, no
42
+ reload. `session.entryPage` is the first page (path + query) of the
43
+ visitor's session, so a rule on it keeps matching after they navigate on.
44
+ Neither needs a `utm_*` param invented for the purpose.
45
+ - **A site-owned "What brings you here?" control** does not need the built-in
46
+ hello-bar widget. Call `Kywi.setSelfId({ role: 'marketer' })`: it records the
47
+ answer, resolves the audience server-side, pins it and reloads, so the server
48
+ re-renders with that audience's arms. `Kywi.clearSelfId()` undoes it. With
49
+ JavaScript off, a plain `<form method="post" action="/api/v1/kywi/self-id">`
50
+ carrying a `returnTo` field does the same thing and 303s back to the page.
51
+
52
+ Two things about it that are easy to get wrong:
53
+
54
+ - **Declare the self-ID field first** (Admin → Audiences → Self-ID Fields).
55
+ The endpoint stores only the fields the site declared, so submitting
56
+ `{ role }` to a site with no `role` field stores nothing and matches
57
+ nothing.
58
+ - **An audience with no rules never matches from answers.** Resolution runs
59
+ the same rules the rest of the engine does; an audience with zero condition
60
+ groups matches nobody. If you want a chooser whose options ARE audiences,
61
+ flag them **public** in the audience editor, read the options from
62
+ `GET /api/v1/kywi/audiences` (`{ id, name, label }`, never the rules), and
63
+ pass the id back: `Kywi.setSelfId({}, { audienceId })`, or an
64
+ `<input type="hidden" name="audienceId">` on the no-JS form. That pins the
65
+ audience the visitor picked directly, rules or no rules.
66
+
37
67
  ### 4a. Page-variant path
38
68
 
39
69
  In the layout editor on the target page: **+ Page Variant** → select the
package/lib/templates.mjs CHANGED
@@ -44,6 +44,22 @@ const CORE_RANGE = (v) => `^${v}`
44
44
 
45
45
  /** @param {Answers} a */
46
46
  function packageJson(a) {
47
+ // Coupled mode's front-of-site editor (kywi-front-edit.tsx) needs the admin
48
+ // design system's stylesheet available at a plain, fetchable URL
49
+ // (/kywi-admin.css) — it is injected via a runtime <link> tag rather than a
50
+ // JS import (kywi-cms#130 follow-up: an import, even a dynamic one, still
51
+ // ships the stylesheet on every public route — see ensureAdminStylesheet in
52
+ // kywi-front-edit.tsx). Unlike public/kywi.js (opt-in, manually placed when
53
+ // personalization.clientRuntime is enabled — see README), this is a CORE
54
+ // feature every coupled-mode app has, so the sync is automatic.
55
+ const syncScripts =
56
+ a.mode === 'coupled'
57
+ ? {
58
+ 'sync:kywi-admin-css': 'node scripts/sync-kywi-admin-css.mjs',
59
+ predev: 'npm run sync:kywi-admin-css',
60
+ prebuild: 'npm run sync:kywi-admin-css',
61
+ }
62
+ : {}
47
63
  return JSON.stringify(
48
64
  {
49
65
  name: a.projectName,
@@ -54,6 +70,7 @@ function packageJson(a) {
54
70
  dev: 'next dev',
55
71
  build: 'next build',
56
72
  start: 'next start',
73
+ ...syncScripts,
57
74
  // Plain `kywi migrate` (not --push): the CLI bootstraps a migration
58
75
  // journal via `drizzle-kit generate` on first run and grows it on
59
76
  // every schema-bearing release after (kywi-cms#141) — a fresh
@@ -101,6 +118,63 @@ function packageJson(a) {
101
118
  ) + '\n'
102
119
  }
103
120
 
121
+ // ── scripts/sync-kywi-admin-css.mjs ─────────────────────────────────────────
122
+
123
+ /**
124
+ * Syncs the admin design system's stylesheet (and a cache-busting version
125
+ * marker) into public/, so the front-edit overlay's `ensureAdminStylesheet`
126
+ * can load it via a plain `<link>` tag (kywi-cms#130) instead of a JS/CSS
127
+ * module import — which Next's App Router CSS collection would otherwise
128
+ * bundle into every public route regardless of dynamic() boundaries.
129
+ *
130
+ * Runs on every predev/prebuild (package.json's `sync:kywi-admin-css`) — NOT
131
+ * committed (see the generated .gitignore) — so a core upgrade always serves
132
+ * fresh admin CSS under a cache-busted URL (`?v=<core version>`) rather than
133
+ * a stale one a CDN/browser cached under the unversioned `/kywi-admin.css`
134
+ * path.
135
+ */
136
+ function syncKywiAdminCssScript() {
137
+ return `#!/usr/bin/env node
138
+ import { copyFileSync, writeFileSync, existsSync, readFileSync } from 'node:fs'
139
+ import { dirname, join } from 'node:path'
140
+ import { createRequire } from 'node:module'
141
+
142
+ const require = createRequire(import.meta.url)
143
+
144
+ try {
145
+ const cssPath = require.resolve('@kywi-software/core/admin/styles.css')
146
+
147
+ // Find @kywi-software/core's OWN package.json (for its version) by walking
148
+ // up from the resolved stylesheet path: core's package.json "exports" map
149
+ // does not list "./package.json", so require.resolve() can't reach it
150
+ // directly.
151
+ let dir = dirname(cssPath)
152
+ let version = ''
153
+ for (;;) {
154
+ const pkgPath = join(dir, 'package.json')
155
+ if (existsSync(pkgPath)) {
156
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
157
+ if (pkg.name === '@kywi-software/core') {
158
+ version = pkg.version
159
+ break
160
+ }
161
+ }
162
+ const parent = dirname(dir)
163
+ if (parent === dir) break // reached the filesystem root without finding it
164
+ dir = parent
165
+ }
166
+
167
+ copyFileSync(cssPath, 'public/kywi-admin.css')
168
+ // A plain-text sibling asset, not a JS/JSON import — ensureAdminStylesheet
169
+ // fetches it at runtime, so this never touches the CSS/JS module graph.
170
+ writeFileSync('public/kywi-admin.css.version', version)
171
+ console.log(\`synced public/kywi-admin.css (v\${version || 'unknown'})\`)
172
+ } catch (e) {
173
+ console.warn('kywi-admin.css sync skipped:', e.message)
174
+ }
175
+ `
176
+ }
177
+
104
178
  // ── kywi.config.ts ────────────────────────────────────────────────────────────
105
179
 
106
180
  /** @param {Answers} a */
@@ -258,25 +332,38 @@ function tsconfig() {
258
332
  /**
259
333
  * Ambient declaration for stylesheet imports.
260
334
  *
261
- * WHY THIS FILE EXISTS (found by the scaffold compile gate, kywi-cms#147 T6).
262
- * `app/(site)/kywi-front-edit.tsx` pulls the admin design system in LAZILY —
263
- * `await import('@kywi-software/core/admin/styles.css')` inside `next/dynamic`'s
264
- * factory so the ~4.7k-line stylesheet never enters the public browse bundle.
265
- * TypeScript treats a *dynamic* import of a non-code file as an ordinary module
266
- * import and reports TS2307 for it, unlike a top-level `import 'x.css'` statement,
267
- * which it lets through as a resource import. Next type-checks during `next build`,
268
- * so without this declaration a freshly scaffolded project failed its own build on
269
- * generated code — which nothing caught, because the reference app imports that
270
- * stylesheet in statement form and the scaffold's own output had never been
271
- * type-checked at all.
335
+ * WHY THIS FILE EXISTS (found by the scaffold compile gate, kywi-cms#147 T6;
336
+ * history below). `app/(site)/kywi-front-edit.tsx` originally pulled the
337
+ * admin design system in via `await import('@kywi-software/core/admin/
338
+ * styles.css')` inside `next/dynamic`'s factory. TypeScript treats a
339
+ * *dynamic* import of a non-code file as an ordinary module import and
340
+ * reports TS2307 for it, unlike a top-level `import 'x.css'` statement, which
341
+ * it lets through as a resource import. Next type-checks during `next
342
+ * build`, so without this declaration a freshly scaffolded project failed
343
+ * its own build on generated code — which nothing caught, because the
344
+ * reference app imported that stylesheet in statement form and the
345
+ * scaffold's own output had never been type-checked at all.
346
+ *
347
+ * kywi-cms#130 follow-up: that dynamic import turned out to ship the
348
+ * stylesheet to every public route regardless of the `next/dynamic`
349
+ * wrapping (Next's App Router CSS collection bundles every css import it can
350
+ * see in the reachable module graph, dynamic or not) — see
351
+ * `ensureAdminStylesheet` in `frontEditOverlay()`, which replaced it with a
352
+ * runtime `<link>` tag. This ambient declaration is no longer load-bearing
353
+ * for that one case, but it costs nothing to keep as a general-purpose `*.css`
354
+ * declaration for any future dynamic stylesheet import.
272
355
  *
273
356
  * `*.module.css` is deliberately NOT declared here: Next's own types declare it
274
357
  * with a real `classes` record, and the more specific pattern wins.
275
358
  */
276
359
  function cssTypes() {
277
- return `// Stylesheet imports (including the lazy \`await import('…/styles.css')\` in
278
- // app/(site)/kywi-front-edit.tsx, which TypeScript checks like any other module).
279
- // CSS Modules are typed by Next itself — the more specific pattern wins.
360
+ return `// Stylesheet imports. CSS Modules are typed by Next itself — the more
361
+ // specific pattern wins. (History: originally required for a dynamic
362
+ // \`await import('…/styles.css')\` in app/(site)/kywi-front-edit.tsx, which
363
+ // TypeScript checks like any other module — see cssTypes' doc comment in
364
+ // templates.mjs. That import was replaced by a runtime \`<link>\` tag,
365
+ // kywi-cms#130 follow-up, so nothing in this generated app currently needs
366
+ // this declaration — kept for any future dynamic stylesheet import.)
280
367
  declare module '*.css'
281
368
  `
282
369
  }
@@ -359,6 +446,12 @@ function gitignore() {
359
446
  .env
360
447
  .env.local
361
448
  *.log
449
+
450
+ # Regenerated on every predev/prebuild (scripts/sync-kywi-admin-css.mjs) from
451
+ # the installed @kywi-software/core package — kywi-cms#130 follow-up. Coupled
452
+ # mode only, but harmless to list unconditionally.
453
+ public/kywi-admin.css
454
+ public/kywi-admin.css.version
362
455
  `
363
456
  }
364
457
 
@@ -459,6 +552,7 @@ import {
459
552
  listSelfIdFields,
460
553
  COOKIE_NAMES,
461
554
  } from '@kywi-software/core/audiences'
555
+ import { cookieAllowedByHeader } from '@kywi-software/core/audiences/consent'
462
556
  import type {
463
557
  Audience,
464
558
  VisitorSignals,
@@ -670,6 +764,12 @@ export interface PublicPersonalization {
670
764
  audienceId: string | null
671
765
  /** Stable visitor id (middleware cookie/header); keys A/B assignment. */
672
766
  visitorId: string
767
+ /**
768
+ * True when \`visitorId\` is a durable identity this visitor consented to
769
+ * (kywi-cms#91). False means it was minted for this request alone — nothing
770
+ * may be written against it, including an A/B exposure.
771
+ */
772
+ tracked: boolean
673
773
  /** Active audiences evaluated — handed to the client runtime for re-eval. */
674
774
  audiences: Audience[]
675
775
  /** Server-resolved signals — handed to the client runtime as serverSignals. */
@@ -696,8 +796,26 @@ export async function resolvePersonalization(
696
796
  layout: LayoutDocument | null | undefined,
697
797
  ): Promise<PublicPersonalization> {
698
798
  const [h, cookieStore] = await Promise.all([headers(), cookies()])
799
+ // One resolution of \`theme.personalization.requireConsent\` for the whole
800
+ // request, handed to every gate below — the visitor-id read AND the audience
801
+ // engine's own collectors. They read the same cookies; disagreeing about
802
+ // whether consent is required would personalize half a page.
803
+ const gate = { requireConsent: requireConsentEnabled() }
804
+ // \`kywi_visitor\` is a \`personalization\` cookie, so without consent it is
805
+ // neither written nor read (kywi-cms#91) — the middleware hands this request a
806
+ // THROWAWAY id instead, freshly minted and different on the next request.
807
+ // \`tracked\` carries that fact to everything downstream that would otherwise
808
+ // write the id to the database.
809
+ const tracked = cookieAllowedByHeader(COOKIE_NAMES.VISITOR, h.get('cookie'), gate)
810
+ // The stored cookie is read THROUGH that gate too, not just written through
811
+ // it: a visitor who accepted last month and has since rejected still carries
812
+ // one, and honouring it would re-identify them under the identity they
813
+ // revoked. The header bridge is unaffected — the middleware already applied
814
+ // the same rule when it minted the id this request carries.
699
815
  const visitorId =
700
- h.get('x-kywi-visitor') ?? cookieStore.get(COOKIE_NAMES.VISITOR)?.value ?? 'vis-anon'
816
+ h.get('x-kywi-visitor') ??
817
+ (tracked ? cookieStore.get(COOKIE_NAMES.VISITOR)?.value : undefined) ??
818
+ 'vis-anon'
701
819
  const host = h.get('host') ?? 'localhost'
702
820
  const url = h.get('x-kywi-url') ?? \`http://\${host}/\`
703
821
 
@@ -706,17 +824,28 @@ export async function resolvePersonalization(
706
824
  const request = new Request(url, { headers: reqHeaders })
707
825
 
708
826
  const { db, siteId } = runtime
709
- const { result, audiences } = await evaluateActiveAudiences(db, siteId, request)
827
+ // \`gate\` reaches the engine's server collectors, so a site whose consent is
828
+ // owned by an external CMP (\`requireConsent: false\`) has its stored UTM /
829
+ // visitor / known / pinned-audience cookies read here too, not just by the
830
+ // middleware and the runtime routes.
831
+ const { result, audiences } = await evaluateActiveAudiences(db, siteId, request, gate)
710
832
 
711
833
  const previewId = cookieStore.get(COOKIE_NAMES.PREVIEW_INIT)?.value || null
712
834
  const audienceId = isActiveAudience(previewId, audiences) ? previewId : result.winningAudienceId
713
835
 
714
- const experimentAssignments = await resolveExperimentAssignments(runtime, layout, audienceId, visitorId)
836
+ const experimentAssignments = await resolveExperimentAssignments(
837
+ runtime,
838
+ layout,
839
+ audienceId,
840
+ visitorId,
841
+ tracked,
842
+ )
715
843
 
716
844
  return {
717
845
  personalization: { resolvedAudienceId: audienceId, experimentAssignments },
718
846
  audienceId,
719
847
  visitorId,
848
+ tracked,
720
849
  audiences,
721
850
  signals: result.signals,
722
851
  }
@@ -736,12 +865,19 @@ function* variantContainersInRegions(regions: Record<string, RegionNode[]>): Gen
736
865
  * exposure. Stable on visitorId → the visitor keeps the same arm across requests;
737
866
  * the write is idempotent on (experiment, visitor). Returns experimentId →
738
867
  * chosen variant key for <KywiLayout personalization.experimentAssignments>.
868
+ *
869
+ * \`tracked\` is the consent gate (kywi-cms#91): false means \`visitorId\` is a
870
+ * throwaway the middleware minted for this request alone, and the idempotent
871
+ * write stops being idempotent — every anonymous page view would insert another
872
+ * assignment row. The ARM IS STILL CHOSEN, so the page renders exactly as it
873
+ * does for anyone else; only the counting stops.
739
874
  */
740
875
  async function resolveExperimentAssignments(
741
876
  runtime: KywiRuntime,
742
877
  layout: LayoutDocument | null | undefined,
743
878
  audienceId: string | null,
744
879
  visitorId: string,
880
+ tracked: boolean,
745
881
  ): Promise<Record<string, string | null>> {
746
882
  if (!layout) return {}
747
883
  // Pre-resolve FIRST, then read the containers. A container component saved
@@ -766,7 +902,7 @@ async function resolveExperimentAssignments(
766
902
  for (const id of experimentIds) {
767
903
  const exp = byId.get(id)
768
904
  if (!exp) continue
769
- const res = await resolveExperimentForContainer(runtime.db, exp, visitorId)
905
+ const res = await resolveExperimentForContainer(runtime.db, exp, visitorId, { recordExposure: tracked })
770
906
  assignments[id] = res.variantKey
771
907
  }
772
908
  return assignments
@@ -775,8 +911,12 @@ async function resolveExperimentAssignments(
775
911
  /**
776
912
  * Return the audience-resolved layout: page variants (audience → whole-page
777
913
  * region override) applied, and \`pageVariants\`/\`abExperiments\` stripped so no
778
- * other audience's content is serialized to this visitor. variantContainer arms
779
- * still resolve at render time from the threaded PersonalizationState.
914
+ * other audience's content is serialized to this visitor.
915
+ *
916
+ * This is only the PAGE-level half. Container-level arms (variantContainer /
917
+ * moduleVariantContainer) are stripped by \`pruneLayoutToServedArms\` as the last
918
+ * pass before \`<KywiLayout>\` — see the public page (#167). Run both, or the
919
+ * losing arms are still readable in the page's RSC flight payload.
780
920
  */
781
921
  export function personalizeLayout(
782
922
  layout: LayoutDocument,
@@ -806,6 +946,21 @@ export function transparencyNoticeEnabled(): boolean {
806
946
  return theme?.personalization?.transparencyNotice?.enabled !== false
807
947
  }
808
948
 
949
+ /**
950
+ * Must a visitor consent before this site writes a \`personalization\` cookie
951
+ * (\`kywi_visitor\`, \`kywi_signals\`, \`kywi_utm\`, \`kywi_audience\`)?
952
+ * \`personalization.requireConsent\` — unset defaults to TRUE; only an explicit
953
+ * \`false\` turns Kywi's consent gate off, for a site that collects consent with
954
+ * external tooling (kywi-cms#91).
955
+ *
956
+ * \`middleware.ts\` runs on the edge and cannot read this config, so it mirrors
957
+ * the same flag with \`KYWI_REQUIRE_CONSENT=false\`. Set both, or neither.
958
+ */
959
+ export function requireConsentEnabled(): boolean {
960
+ const theme = config.themes.find((t) => t.name === config.sites[0]?.theme) ?? config.themes[0]
961
+ return theme?.personalization?.requireConsent !== false
962
+ }
963
+
809
964
  // ─── Self-ID widget (#50) ────────────────────────────────────────────────────
810
965
 
811
966
  /** The self-ID widget config in the serializable shape the client runtime reads. */
@@ -900,6 +1055,26 @@ import {
900
1055
  // back. Its own dependency-free entry (never the DB-backed audiences barrel), so
901
1056
  // it is importable from this edge middleware.
902
1057
  import { UTM_COOKIE, utmCookieValue } from '@kywi-software/core/audiences/utm-persistence'
1058
+ // Same kind of edge-safe leaf: the kywi_entry writer, whose value core reads
1059
+ // back as \`session.entryPage\` (kywi-cms#196).
1060
+ import { ENTRY_COOKIE, entryPageCookieValue } from '@kywi-software/core/audiences/entry-page'
1061
+ // Same kind of edge-safe leaf entry: the cookie-consent category map + gate. The
1062
+ // visitor's decision (written by the \`cookieConsent\` layout module) decides
1063
+ // whether the personalization cookies below may be persisted at all.
1064
+ import { cookieAllowedByHeader } from '@kywi-software/core/audiences/consent'
1065
+
1066
+ /**
1067
+ * The edge mirror of \`theme.personalization.requireConsent\` (kywi-cms#91).
1068
+ * Middleware runs on the edge and must not import \`kywi.config.ts\` (a config is
1069
+ * free to pull in plugins and DB-backed code that cannot run there), so the one
1070
+ * flag that lives in config is mirrored here as an env var.
1071
+ * \`KYWI_REQUIRE_CONSENT=false\` turns the gate off for the two cookies this file
1072
+ * writes. Set it together with the theme flag, never one alone —
1073
+ * createKywiApiHandler THROWS at startup when the two disagree, so a mismatch
1074
+ * fails the deploy instead of silently making the middleware and the API
1075
+ * disagree about whether a visitor is tracked.
1076
+ */
1077
+ const REQUIRE_CONSENT = process.env.KYWI_REQUIRE_CONSENT !== 'false'
903
1078
 
904
1079
  /**
905
1080
  * Server-side auth enforcement + transparent session refresh for /admin and the
@@ -952,24 +1127,56 @@ const VISITOR_MAX_AGE = 60 * 60 * 24 * 365 * 2 // 2 years
952
1127
  * would never fire on the landing page. Everything else passes through.
953
1128
  */
954
1129
  function handlePublicRequest(req: NextRequest): NextResponse {
955
- const existing = req.cookies.get(VISITOR_COOKIE)?.value
1130
+ // Both cookies below are \`personalization\` cookies in core's consent map, so
1131
+ // neither is written OR READ until the visitor accepts. The header bridge is
1132
+ // unaffected — this request still gets a visitor id and still personalizes off
1133
+ // its own URL — so a site with no consent banner still renders, it just serves
1134
+ // default content instead of tracking anyone. Place the \`cookieConsent\`
1135
+ // module to let visitors turn personalization on.
1136
+ const cookieHeader = req.headers.get('cookie')
1137
+ const gate = { requireConsent: REQUIRE_CONSENT }
1138
+ const mayPersonalize = cookieAllowedByHeader(VISITOR_COOKIE, cookieHeader, gate)
1139
+ // Without consent the stored id is IGNORED, not just left un-refreshed: a
1140
+ // visitor who accepted last month and rejected today still carries the cookie,
1141
+ // and reading it would keep them tracked under the identity they just revoked.
1142
+ const existing = mayPersonalize ? req.cookies.get(VISITOR_COOKIE)?.value : undefined
956
1143
  const visitorId = existing ?? crypto.randomUUID()
957
1144
  const headers = new Headers(req.headers)
958
1145
  headers.set('x-kywi-visitor', visitorId)
959
1146
  headers.set('x-kywi-url', req.nextUrl.href)
960
1147
  const res = NextResponse.next({ request: { headers } })
961
- if (!existing) {
1148
+ if (!existing && mayPersonalize) {
962
1149
  res.cookies.set(VISITOR_COOKIE, visitorId, { path: '/', maxAge: VISITOR_MAX_AGE, sameSite: 'lax' })
963
1150
  }
964
1151
  // Persist campaign UTM params so audience matching survives internal
965
1152
  // navigation: core's collectUtm reads this cookie when the URL has no utm_*
966
1153
  // query string. Only written when the request carries utm_* params, so an
967
1154
  // ordinary page view never clobbers a persisted campaign (utmCookieValue
968
- // returns null → the existing cookie is left in place).
969
- const utmValue = utmCookieValue(req.nextUrl.href)
1155
+ // returns null → the existing cookie is left in place) — and only once the
1156
+ // visitor has consented, which utmCookieValue reads off the header we pass.
1157
+ const utmValue = utmCookieValue(req.nextUrl.href, { cookieHeader, ...gate })
970
1158
  if (utmValue) {
971
1159
  res.cookies.set(UTM_COOKIE, utmValue, { path: '/', maxAge: 60 * 60 * 24 * 30, sameSite: 'lax' })
972
1160
  }
1161
+ // Record the session's ENTRY page (path + query) so rules on
1162
+ // \`session.entryPage\` keep matching once the visitor navigates on
1163
+ // (kywi-cms#196). Written once, consent-gated like the two above, and with no
1164
+ // maxAge — a new visit is a new entry page. The landing request needs no
1165
+ // cookie: core falls back to the request's own path, so \`?from=marketer\`
1166
+ // personalizes the page it links to on that very request.
1167
+ // Sec-Fetch-Dest / Accept are passed so ONLY a document request can name the
1168
+ // entry page: a subresource that happens to be the session's first request
1169
+ // (\`/kywi.js\`, a font, a route handler) would otherwise record itself as the
1170
+ // page the visitor arrived on, and every entry-page rule would match an asset.
1171
+ const entryValue = entryPageCookieValue(req.nextUrl.href, {
1172
+ cookieHeader,
1173
+ secFetchDest: req.headers.get('sec-fetch-dest'),
1174
+ accept: req.headers.get('accept'),
1175
+ ...gate,
1176
+ })
1177
+ if (entryValue) {
1178
+ res.cookies.set(ENTRY_COOKIE, entryValue, { path: '/', sameSite: 'lax' })
1179
+ }
973
1180
  return res
974
1181
  }
975
1182
 
@@ -1161,6 +1368,7 @@ import './site.css'
1161
1368
  import config from '../../kywi.config'
1162
1369
  import { getKywi } from '../../lib/kywi'
1163
1370
  import { SiteNav } from '../../components/site-nav'
1371
+ import { KywiJsLoader } from '../../components/kywi-js-loader'
1164
1372
 
1165
1373
  /**
1166
1374
  * Public site shell. Header + footer carry this project's brand; edit them (and
@@ -1208,6 +1416,13 @@ export default async function SiteLayout({ children }: { children: React.ReactNo
1208
1416
  return (
1209
1417
  <div className="site-shell">
1210
1418
  {themeVars ? <style dangerouslySetInnerHTML={{ __html: themeVars }} /> : null}
1419
+ {/* /kywi.js for the nav's hover-intent/keyboard/viewport-flip enhancement
1420
+ (kywi-cms#114) — independent of personalization, gated on the exact
1421
+ same condition <SiteNav> renders on below (a non-empty header menu),
1422
+ so it loads whenever a \`[data-kywi-nav]\` root actually exists. Shares
1423
+ its dedup with <PersonalizationRuntime> (lib/kywi-js-loader.ts), so a
1424
+ page with both nav AND the client runtime enabled loads it once. */}
1425
+ {headerItems.length > 0 && <KywiJsLoader />}
1211
1426
 
1212
1427
  <header className="site-header">
1213
1428
  <div className="site-header__inner">
@@ -1346,7 +1561,15 @@ import type { Metadata } from 'next'
1346
1561
  import { notFound } from 'next/navigation'
1347
1562
  import { cookies, headers } from 'next/headers'
1348
1563
  import { KywiBody, KywiEditableAttribute, KywiEditableRegion } from '@kywi-software/core/scope-client'
1349
- import { KywiLayout, KywiRegion, AudienceMetaTags, hydrateLayoutFeeds, hydrateLayoutNav, type LayoutDocument } from '@kywi-software/core/layout'
1564
+ import {
1565
+ KywiLayout,
1566
+ KywiRegion,
1567
+ AudienceMetaTags,
1568
+ hydrateLayoutFeeds,
1569
+ hydrateLayoutNav,
1570
+ pruneLayoutToServedArms,
1571
+ type LayoutDocument,
1572
+ } from '@kywi-software/core/layout'
1350
1573
  import { KywiJsonLd } from '@kywi-software/core/scope'
1351
1574
  import { ACCESS_COOKIE, canAccessContent, readSessionClaims } from '@kywi-software/core/host'
1352
1575
  import config from '../../../lib/config'
@@ -1358,6 +1581,7 @@ import {
1358
1581
  resolveSelfIdWidget,
1359
1582
  clientRuntimeEnabled,
1360
1583
  transparencyNoticeEnabled,
1584
+ requireConsentEnabled,
1361
1585
  buildFeedResolver,
1362
1586
  resolveLayoutComponents,
1363
1587
  localeAlternates,
@@ -1478,11 +1702,24 @@ export default async function PublicPage({ params, searchParams }: Params & Sear
1478
1702
  // Comments module's content-id anchor (#29); the audience/visitor ids for
1479
1703
  // client tooling (#50); and — when the theme opts in — the optional client
1480
1704
  // runtime that mounts the self-ID widget and re-evaluates audiences.
1705
+ //
1706
+ // \`head\` is a FUNCTION because JSON-LD must describe the layout THIS visitor
1707
+ // is served (#195): the served document only exists after the arms are pruned
1708
+ // below, and building the description from the STORED layout would ship the
1709
+ // DEFAULT arm's copy to a matched visitor — the #167 leak, through structured
1710
+ // data. The no-layout branch passes nothing, and JSON-LD then describes the
1711
+ // node's body/metaDescription/summary alone.
1481
1712
  const baseUrl = await requestBaseUrl()
1482
- const head = (
1713
+ const headFor = (servedLayout?: unknown) => (
1483
1714
  <>
1484
1715
  <meta name="kywi:content-id" content={contentId} />
1485
- <KywiJsonLd node={node} config={runtime.config} baseUrl={baseUrl} siteId={runtime.siteId} />
1716
+ <KywiJsonLd
1717
+ node={node}
1718
+ config={runtime.config}
1719
+ baseUrl={baseUrl}
1720
+ siteId={runtime.siteId}
1721
+ {...(servedLayout ? { layout: servedLayout } : {})}
1722
+ />
1486
1723
  <AudienceMetaTags audienceId={perso.audienceId} visitorId={perso.visitorId} />
1487
1724
  {clientRuntimeEnabled() && perso.audiences.length > 0 ? (
1488
1725
  <PersonalizationRuntime
@@ -1490,6 +1727,7 @@ export default async function PublicPage({ params, searchParams }: Params & Sear
1490
1727
  serverSignals={perso.signals}
1491
1728
  selfIdWidget={selfIdWidget}
1492
1729
  transparencyNotice={transparencyNoticeEnabled()}
1730
+ requireConsent={requireConsentEnabled()}
1493
1731
  />
1494
1732
  ) : null}
1495
1733
  </>
@@ -1519,15 +1757,28 @@ export default async function PublicPage({ params, searchParams }: Params & Sear
1519
1757
  // KywiLayout is a client component and the renderer's resolver props are
1520
1758
  // functions, which cannot cross the RSC boundary (#147).
1521
1759
  const hydrated = await resolveLayoutComponents(navHydrated, runtime)
1760
+ // LAST pass before the client boundary (#167): keep ONLY the arm this
1761
+ // visitor is served in every variantContainer / moduleVariantContainer.
1762
+ // KywiLayout is a client component, so anything still on the document here
1763
+ // is serialized into the page's RSC flight payload — arm SELECTION was
1764
+ // always server-side, but the losing arms crossed with it and were readable
1765
+ // in view-source. Pruning uses the same \`perso.personalization\` the
1766
+ // renderer is handed, so the markup is unchanged.
1767
+ //
1768
+ // Order matters: after component resolution (a container that is only a
1769
+ // \`componentId\` has no arms of its own to prune yet) and after the feed/nav
1770
+ // hydration passes. The front-edit overlay below is given the STORED
1771
+ // \`layout\`, never this — an editor needs every arm.
1772
+ const served = pruneLayoutToServedArms(hydrated, perso.personalization)
1522
1773
  content = (
1523
1774
  <article className="page page--layout" data-kywi-content-id={contentId}>
1524
- {head}
1775
+ {headFor(served)}
1525
1776
  <KywiLayout
1526
- layout={hydrated}
1777
+ layout={served}
1527
1778
  personalization={perso.personalization}
1528
1779
  moduleComponents={moduleComponents}
1529
1780
  >
1530
- {Object.keys(hydrated.regions).map((name) => (
1781
+ {Object.keys(served.regions).map((name) => (
1531
1782
  <KywiRegion key={name} name={name} />
1532
1783
  ))}
1533
1784
  </KywiLayout>
@@ -1536,7 +1787,7 @@ export default async function PublicPage({ params, searchParams }: Params & Sear
1536
1787
  } else {
1537
1788
  content = (
1538
1789
  <article className="page" data-kywi-content-id={contentId}>
1539
- {head}
1790
+ {headFor()}
1540
1791
  {featured ? <img className="page__featured" src={featured} alt="" /> : null}
1541
1792
  <h1 className="page__title">{title}</h1>
1542
1793
  {perms ? (
@@ -1624,14 +1875,84 @@ import {
1624
1875
  } from '@kywi-software/core/layout'
1625
1876
  import type { SaveAction } from '@kywi-software/core/admin'
1626
1877
 
1627
- // Lazy-load the full layout editor AND its stylesheet: the shell bundle (dnd-kit,
1628
- // canvas, panels) and the ~4.7k-line admin design system are pulled INSIDE this
1629
- // factory, so they enter the module graph only when an editor opens the overlay —
1630
- // never in the public browse bundle a visitor downloads.
1878
+ /**
1879
+ * Injects the admin design system's stylesheet as a plain \`<link>\` tag rather
1880
+ * than an ES \`import\` (kywi-cms#130 follow-up). A dynamic \`import('…/admin/
1881
+ * styles.css')\` still shipped the stylesheet on every public route that
1882
+ * reaches this factory, regardless of the \`next/dynamic\` wrapping around
1883
+ * it. A \`<link>\` created imperatively at runtime has no \`import\` statement
1884
+ * for Next's CSS collector to see, so it never enters the build's CSS graph.
1885
+ *
1886
+ * VERIFIED (in the reference app this template mirrors) against a real
1887
+ * \`next build && next start\`: an anonymous fetch of a real published content
1888
+ * page shows zero OverlayShell/editor.css/admin.css references in any of its
1889
+ * fetched resources. Fetching the SAME page against \`next dev\` still shows
1890
+ * every one of those markers regardless of this fix — \`next dev\` bundles far
1891
+ * more eagerly than a production build, for ALL routes, independent of any
1892
+ * dynamic()/runtime-injection technique. A generated app's own e2e coverage
1893
+ * of this (if any) needs to build for real, not assert against \`next dev\`.
1894
+ *
1895
+ * Two follow-ups from review:
1896
+ * - CACHE-BUSTING: \`/kywi-admin.css\` and \`/kywi-admin.css.version\` are both
1897
+ * synced from the installed \`@kywi-software/core\` package
1898
+ * (scripts/sync-kywi-admin-css.mjs, run via package.json's
1899
+ * \`sync:kywi-admin-css\` on every \`predev\`/\`prebuild\`) — mirroring how
1900
+ * \`public/kywi.js\` is synced from \`@kywi-software/js\`. The version
1901
+ * file's content becomes the \`?v=\` query string below, so a core upgrade
1902
+ * that changes the stylesheet always serves a URL a CDN/browser has never
1903
+ * cached, instead of a stale \`/kywi-admin.css\` response.
1904
+ * - FOUC: returns a Promise that resolves once the tag has actually loaded
1905
+ * (or failed to) — an existing, already-loaded tag resolves immediately;
1906
+ * a fresh or still-loading one resolves on its \`load\`/\`error\` event. The
1907
+ * dynamic() factory below awaits this ALONGSIDE the OverlayShell JS
1908
+ * import, so the editor's first paint never races its own stylesheet.
1909
+ */
1910
+ function ensureAdminStylesheet(): Promise<void> {
1911
+ if (typeof document === 'undefined') return Promise.resolve()
1912
+ const existing = document.querySelector<HTMLLinkElement>('link[data-kywi-admin-css]')
1913
+ if (existing) {
1914
+ // \`.sheet\` is non-null only once the stylesheet has actually parsed —
1915
+ // resolve immediately rather than re-attaching listeners to an event
1916
+ // that already fired.
1917
+ if (existing.sheet) return Promise.resolve()
1918
+ return new Promise((resolve) => {
1919
+ existing.addEventListener('load', () => resolve(), { once: true })
1920
+ existing.addEventListener('error', () => resolve(), { once: true })
1921
+ })
1922
+ }
1923
+ return fetch('/kywi-admin.css.version')
1924
+ .then((res) => (res.ok ? res.text() : ''))
1925
+ .catch(() => '')
1926
+ .then(
1927
+ (version) =>
1928
+ new Promise<void>((resolve) => {
1929
+ const link = document.createElement('link')
1930
+ link.rel = 'stylesheet'
1931
+ const v = version.trim()
1932
+ link.href = v ? \`/kywi-admin.css?v=\${encodeURIComponent(v)}\` : '/kywi-admin.css'
1933
+ link.dataset.kywiAdminCss = 'true'
1934
+ // A load OR error either way lets the caller proceed — a stylesheet
1935
+ // that 404s must never permanently block the editor from opening.
1936
+ link.addEventListener('load', () => resolve(), { once: true })
1937
+ link.addEventListener('error', () => resolve(), { once: true })
1938
+ document.head.appendChild(link)
1939
+ }),
1940
+ )
1941
+ }
1942
+
1943
+ // Lazy-load the full layout editor bundle: the shell's JS (dnd-kit, canvas,
1944
+ // panels) is pulled INSIDE this factory, so it enters the module graph only
1945
+ // when an editor opens the overlay — never in the public browse bundle a
1946
+ // visitor downloads. The stylesheet is handled separately — see
1947
+ // \`ensureAdminStylesheet\` above — since a css import here, even a dynamic
1948
+ // one, does not behave like the JS import right below it. Both are awaited
1949
+ // together so the editor never paints ahead of its own stylesheet (FOUC).
1631
1950
  const OverlayShell = dynamic(
1632
1951
  async () => {
1633
- await import('@kywi-software/core/admin/styles.css')
1634
- const mod = await import('@kywi-software/core/admin')
1952
+ const [, mod] = await Promise.all([
1953
+ ensureAdminStylesheet(),
1954
+ import('@kywi-software/core/admin'),
1955
+ ])
1635
1956
  return mod.OverlayShell
1636
1957
  },
1637
1958
  { ssr: false },
@@ -1804,6 +2125,62 @@ export function KywiFrontEdit({
1804
2125
  `
1805
2126
  }
1806
2127
 
2128
+ /**
2129
+ * Shared `/kywi.js` script-injection primitive (kywi-cms#114). Two independent
2130
+ * triggers can each want the browser bundle on the page: the site layout's nav
2131
+ * (`<KywiJsLoader>`, purely for the hover-intent/keyboard/viewport-flip
2132
+ * enhancement `initNavMenus()` applies unconditionally once the script boots)
2133
+ * and `<PersonalizationRuntime>` (which additionally waits on
2134
+ * `window.Kywi.bootAudienceEngine`). Before this fix only the personalization
2135
+ * runtime ever injected the tag, so a site with `personalization.clientRuntime`
2136
+ * off — or simply no active audiences — never got the nav enhancement either,
2137
+ * even though the nav renders fully without it (kywi-cms#111's JS-off contract)
2138
+ * and would only ever gain from it. `ensureKywiJsScript` is idempotent:
2139
+ * whichever caller mounts first creates the ONE `<script data-kywi-js>` tag,
2140
+ * every other caller finds it already there — so both triggers being true at
2141
+ * once never loads the bundle twice.
2142
+ */
2143
+ function kywiJsLoaderLib() {
2144
+ return `export function ensureKywiJsScript(): HTMLScriptElement | null {
2145
+ if (typeof document === 'undefined') return null
2146
+ const existing = document.querySelector<HTMLScriptElement>('script[data-kywi-js]')
2147
+ if (existing) return existing
2148
+ const script = document.createElement('script')
2149
+ script.src = '/kywi.js'
2150
+ script.async = true
2151
+ script.dataset.kywiJs = 'true'
2152
+ document.body.appendChild(script)
2153
+ return script
2154
+ }
2155
+ `
2156
+ }
2157
+
2158
+ /**
2159
+ * Loads `/kywi.js` for its nav enhancement alone, independent of
2160
+ * personalization (kywi-cms#114). Rendered by the site layout whenever the
2161
+ * resolved header menu is non-empty — the same condition `<SiteNav>` itself
2162
+ * gates on before it renders a `[data-kywi-nav]` root — so this mounts exactly
2163
+ * when there is a nav for `initNavMenus()` to enhance. Does nothing beyond
2164
+ * ensuring the script tag exists: `Kywi.boot()` runs `initNavMenus()`
2165
+ * unconditionally as part of its own bootstrap once the script loads, and
2166
+ * never touches personalization on its own.
2167
+ */
2168
+ function kywiJsLoaderComponent() {
2169
+ return `'use client'
2170
+
2171
+ import { useEffect } from 'react'
2172
+ import { ensureKywiJsScript } from '../lib/kywi-js-loader'
2173
+
2174
+ export function KywiJsLoader() {
2175
+ useEffect(() => {
2176
+ ensureKywiJsScript()
2177
+ }, [])
2178
+
2179
+ return null
2180
+ }
2181
+ `
2182
+ }
2183
+
1807
2184
  /**
1808
2185
  * Client personalization runtime (kywi-cms#50). The SERVER already resolved and
1809
2186
  * rendered the correct audience/experiment variant and set the ids in <head>;
@@ -1814,6 +2191,12 @@ export function KywiFrontEdit({
1814
2191
  * stays dependency-free by default: drop the built @kywi-software/js bundle at
1815
2192
  * public/kywi.js to enable it (see README → "Personalization"). Without it the
1816
2193
  * server-rendered variant is exactly what every visitor sees.
2194
+ *
2195
+ * kywi-cms#114: `/kywi.js` may already be on the page independent of this
2196
+ * component — the site layout's `<KywiJsLoader>` loads it purely for nav
2197
+ * enhancement whenever the site has a nav. `ensureKywiJsScript` (shared with
2198
+ * that component, lib/kywi-js-loader.ts) is idempotent, so this never injects
2199
+ * a second copy.
1817
2200
  */
1818
2201
  function personalizationRuntime() {
1819
2202
  return `'use client'
@@ -1821,6 +2204,7 @@ function personalizationRuntime() {
1821
2204
  import React, { useEffect } from 'react'
1822
2205
  import type { Audience, VisitorSignals } from '@kywi-software/core/audiences/types'
1823
2206
  import type { PublicSelfIdWidget } from '../lib/site'
2207
+ import { ensureKywiJsScript } from '../lib/kywi-js-loader'
1824
2208
 
1825
2209
  /* eslint-disable @typescript-eslint/no-explicit-any */
1826
2210
  declare global {
@@ -1843,15 +2227,27 @@ interface PersonalizationRuntimeProps {
1843
2227
  * deliberately through a \`personalizationBadge\` module. Defaults to true.
1844
2228
  */
1845
2229
  transparencyNotice?: boolean
2230
+ /**
2231
+ * \`personalization.requireConsent\` — false turns the client's consent gate
2232
+ * off for a site that collects consent with external tooling. Defaults to
2233
+ * true, so the resolved audience is only remembered for a consenting visitor
2234
+ * (kywi-cms#91).
2235
+ */
2236
+ requireConsent?: boolean
1846
2237
  }
1847
2238
 
1848
2239
  /**
1849
- * Loads /kywi.js and boots the audience data-layer. bootAudienceEngine reads the
1850
- * opt-out cookie itself and resolves opted-out visitors to the default
1851
- * experience, so opt-out is respected end to end. Rendered only when
1852
- * theme.personalization.clientRuntime is on.
2240
+ * Ensures /kywi.js is on the page, then boots the audience data-layer.
2241
+ * bootAudienceEngine reads the opt-out cookie itself and resolves opted-out
2242
+ * visitors to the default experience, so opt-out is respected end to end.
2243
+ * Rendered only when theme.personalization.clientRuntime is on.
2244
+ *
2245
+ * kywi-cms#114: the script tag may already be on the page independent of this
2246
+ * component — the site layout's <KywiJsLoader> loads it purely for nav
2247
+ * enhancement whenever the site has a nav. ensureKywiJsScript (shared with
2248
+ * that component) is idempotent, so this never injects a second copy.
1853
2249
  */
1854
- export function PersonalizationRuntime({ audiences, serverSignals, selfIdWidget, transparencyNotice }: PersonalizationRuntimeProps) {
2250
+ export function PersonalizationRuntime({ audiences, serverSignals, selfIdWidget, transparencyNotice, requireConsent }: PersonalizationRuntimeProps) {
1855
2251
  useEffect(() => {
1856
2252
  let cancelled = false
1857
2253
  const ready = () => typeof window.Kywi?.bootAudienceEngine === 'function'
@@ -1868,6 +2264,7 @@ export function PersonalizationRuntime({ audiences, serverSignals, selfIdWidget,
1868
2264
  ? { selfIdWidget: { ...selfIdWidget, audiences, currentPath } }
1869
2265
  : {}),
1870
2266
  ...(transparencyNotice === false ? { transparencyNotice: false } : {}),
2267
+ ...(requireConsent === false ? { requireConsent: false } : {}),
1871
2268
  })
1872
2269
  .catch(() => {
1873
2270
  /* client personalization is best-effort; the server already rendered defaults */
@@ -1888,18 +2285,14 @@ export function PersonalizationRuntime({ audiences, serverSignals, selfIdWidget,
1888
2285
  }, 60)
1889
2286
  }
1890
2287
 
1891
- const existing = document.querySelector<HTMLScriptElement>('script[data-kywi-js]')
1892
- if (existing) {
1893
- if (ready()) boot()
1894
- else existing.addEventListener('load', whenReady)
1895
- } else {
1896
- const script = document.createElement('script')
1897
- script.src = '/kywi.js'
1898
- script.async = true
1899
- script.dataset.kywiJs = 'true'
1900
- script.addEventListener('load', whenReady)
1901
- document.body.appendChild(script)
1902
- }
2288
+ // kywi-cms#114: the script tag itself may already be on the page — the
2289
+ // site layout's <KywiJsLoader> injects it independently, purely for nav
2290
+ // enhancement, whenever the site has a nav. \`ensureKywiJsScript\` is the
2291
+ // shared dedup primitive: it returns the existing tag if one is already
2292
+ // there, so this never creates a second one.
2293
+ const script = ensureKywiJsScript()
2294
+ if (ready()) boot()
2295
+ else script?.addEventListener('load', whenReady)
1903
2296
 
1904
2297
  return () => {
1905
2298
  cancelled = true
@@ -2243,6 +2636,14 @@ or read-only visitor gets no toolbar and no extra client JS. Custom
2243
2636
  editor's insert palette and are fully editable in place — the props rail and
2244
2637
  inline text editing work exactly as they do in the admin's Layout tab.
2245
2638
 
2639
+ The admin stylesheet (\`public/kywi-admin.css\`) is synced automatically from
2640
+ your installed \`@kywi-software/core\` version on every \`dev\`/\`build\` (via
2641
+ \`predev\`/\`prebuild\` — see \`scripts/sync-kywi-admin-css.mjs\`), not on
2642
+ \`install\`, and is gitignored — it is a build artifact of whichever core
2643
+ version you have, not project source. Run \`pnpm sync:kywi-admin-css\`
2644
+ yourself if you ever need it without a full dev/build cycle (e.g. before
2645
+ \`next start\` against an already-built \`.next\`).
2646
+
2246
2647
  ## Personalization, A/B testing & self-ID
2247
2648
 
2248
2649
  Audiences, experiments and the self-ID widget you configure in the admin resolve
@@ -2252,7 +2653,16 @@ Audiences, experiments and the self-ID widget you configure in the admin resolve
2252
2653
  audience \`variantContainer\` render for the winning audience.
2253
2654
  - **A/B experiments** — each visitor is assigned a variant arm deterministically
2254
2655
  and the exposure is recorded. The assignment is stable because the
2255
- \`middleware.ts\` gives every visitor a persistent \`kywi_visitor\` id.
2656
+ \`middleware.ts\` gives every visitor a persistent \`kywi_visitor\` id — once
2657
+ they have consented. \`kywi_visitor\`, \`kywi_utm\`, \`kywi_signals\` and
2658
+ \`kywi_audience\` are \`personalization\` cookies, and the engine will not write
2659
+ any of them until the visitor accepts. Place the \`cookieConsent\` module to
2660
+ give them that choice; until then pages render their default content and
2661
+ nobody is tracked. If this site collects consent with EXTERNAL
2662
+ tooling instead, set \`personalization: { requireConsent: false }\` on your
2663
+ theme in \`kywi.config.ts\` **and** \`KYWI_REQUIRE_CONSENT=false\` in the
2664
+ environment (the edge middleware cannot read the config) — that hands
2665
+ compliance to your tooling and restores the un-gated cookie behaviour.
2256
2666
  - **Preview links** — an admin preview link sets \`kywi_preview_init\` and the page
2257
2667
  honours it, so you can preview an audience's experience.
2258
2668
 
@@ -2263,6 +2673,15 @@ dropping the built \`@kywi-software/js\` browser bundle at \`public/kywi.js\`; t
2263
2673
  generated \`components/personalization-runtime.tsx\` loads it best-effort. Without
2264
2674
  it, the server-rendered variant is what every visitor sees.
2265
2675
 
2676
+ The same \`public/kywi.js\` bundle also drives the header nav's hover-intent,
2677
+ Escape/arrow-key and viewport-edge-flip enhancement — independent of
2678
+ personalization. \`components/kywi-js-loader.tsx\` loads it whenever the site has
2679
+ a nav, whether or not \`clientRuntime\` is on, so the nav gets that polish on
2680
+ every site once the bundle is in place; the nav renders and works fully without
2681
+ it either way. (\`public/kywi-admin.css\`, the front-of-site editor's stylesheet,
2682
+ is a separate file synced automatically on \`dev\`/\`build\` — see "Front-of-site
2683
+ editor" above — unlike \`kywi.js\`, which you place yourself.)
2684
+
2266
2685
  ## Locales
2267
2686
 
2268
2687
  Set \`locales\` on your site in \`kywi.config.ts\` (e.g. \`locales: ['en', 'es']\`),
@@ -2361,7 +2780,7 @@ lib/config.ts single import path for kywi.config.ts
2361
2780
  app/api/v1/[...kywi]/route.ts the versioned API (delegates to core)
2362
2781
  app/admin/[[...admin]]/page.tsx mounts the FULL core admin (all surfaces) at /admin
2363
2782
  lib/modules.tsx custom (defineModule) module renderers (admin + public)
2364
- app/{llms,robots,sitemap,…} root AX routes (llms.txt, robots.txt, sitemap.xml, …)${a.mode === 'coupled' ? '\napp/(site)/layout.tsx public shell: theme tokens + your header/footer\napp/(site)/site.css your site chrome styles (edit freely)\napp/(site)/[[...slug]]/page.tsx renders published pages (layout + SEO + JSON-LD + i18n)\nlib/site.ts public-render helpers: path/locale resolution, feeds, personalization\ncomponents/site-nav.tsx client wrapper around the shared nav renderer (header + footer)\ncomponents/personalization-runtime.tsx optional client runtime (self-ID widget, live re-eval)\napp/(site)/kywi-front-edit.tsx front-of-site editor: browse toolbar + in-place Layout editor (?kywi-edit=1, lazy-loaded)' : '\napp/page.tsx returns 404 (no public rendering in this mode)'}
2783
+ app/{llms,robots,sitemap,…} root AX routes (llms.txt, robots.txt, sitemap.xml, …)${a.mode === 'coupled' ? '\napp/(site)/layout.tsx public shell: theme tokens + your header/footer\napp/(site)/site.css your site chrome styles (edit freely)\napp/(site)/[[...slug]]/page.tsx renders published pages (layout + SEO + JSON-LD + i18n)\nlib/site.ts public-render helpers: path/locale resolution, feeds, personalization\ncomponents/site-nav.tsx client wrapper around the shared nav renderer (header + footer)\ncomponents/kywi-js-loader.tsx loads /kywi.js for nav enhancement whenever the site has a nav\ncomponents/personalization-runtime.tsx optional client runtime (self-ID widget, live re-eval)\napp/(site)/kywi-front-edit.tsx front-of-site editor: browse toolbar + in-place Layout editor (?kywi-edit=1, lazy-loaded)' : '\napp/page.tsx returns 404 (no public rendering in this mode)'}
2365
2784
  \`\`\`
2366
2785
  `
2367
2786
  }
@@ -2757,10 +3176,19 @@ export function buildFileSet(answers) {
2757
3176
  // Public-render helpers: path/locale resolution, feed + component resolvers,
2758
3177
  // personalization + experiments.
2759
3178
  files['lib/site.ts'] = libSite()
3179
+ // Shared /kywi.js script-injection primitive (kywi-cms#114) — the site
3180
+ // layout's nav loader and the personalization runtime below both use it,
3181
+ // so loading the bundle for either reason never double-loads it.
3182
+ files['lib/kywi-js-loader.ts'] = kywiJsLoaderLib()
3183
+ files['components/kywi-js-loader.tsx'] = kywiJsLoaderComponent()
2760
3184
  // Optional client personalization runtime (self-ID widget, live re-eval). #50
2761
3185
  files['components/personalization-runtime.tsx'] = personalizationRuntime()
2762
3186
  // Front-of-site edit overlay (?kywi-edit=1), mounted by the page above.
2763
3187
  files['app/(site)/kywi-front-edit.tsx'] = frontEditOverlay()
3188
+ // Syncs the admin stylesheet + a cache-busting version marker into
3189
+ // public/ on every predev/prebuild — see the doc comment on
3190
+ // syncKywiAdminCssScript and packageJson's syncScripts.
3191
+ files['scripts/sync-kywi-admin-css.mjs'] = syncKywiAdminCssScript()
2764
3192
  } else {
2765
3193
  // headless + decoupled: no public rendering.
2766
3194
  files['app/page.tsx'] = headlessHomePage(answers)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-kywi-app",
3
- "version": "0.14.0",
3
+ "version": "0.15.1",
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",