create-kywi-app 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -360,11 +405,66 @@ authored elsewhere in the admin).
360
405
  | `personalizationBadge` | Optional inline status pill that opens the global transparency panel — not needed on ordinary pages (§8) |
361
406
  | `socialShare` | Share links for a URL/title across networks |
362
407
  | `map` | Embedded map at a lat/lng or address |
363
- | `cookieConsent` | Consent banner with message + privacy-policy link |
408
+ | `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
409
  | `component` | Places a saved reusable component by slug (§4) |
365
410
  | `form` | Renders a form built in the Forms admin (§6) |
366
411
  | `formEmbed` | Embeds a form by slug — lighter-weight placement of the same |
367
412
 
413
+ #### Cookie consent is enforced, not decorative
414
+
415
+ `cookieConsent` is wired to the engine. Its buttons write `kywi_consent`, and
416
+ `@kywi-software/core`'s `audiences/consent.ts` holds the one map from cookie name
417
+ to category that every optional write is gated on:
418
+
419
+ | Cookie | Category | Gated? |
420
+ |---|---|---|
421
+ | `kywi_visitor`, `kywi_signals`, `kywi_utm`, `kywi_audience`, `kywi_known` | `personalization` | yes |
422
+ | `kywi_consent`, `kywi_optout`, `kywi_preview_init` | `essential` | no — necessary cookies are never gated |
423
+
424
+ A gated cookie is neither **written** nor **read** without consent: a visitor who
425
+ accepted last month and rejects today has those cookies deleted, and any that
426
+ survive (`kywi_signals` is HttpOnly) are ignored on the way in. Rejecting is a
427
+ revocation, not just a pause.
428
+
429
+ One exemption: an audience the visitor picks **by hand** in the transparency
430
+ panel is stored as an `essential` preference, on the same argument as
431
+ `kywi_optout` — a control they operated deliberately has to be honoured, or the
432
+ panel is lying about what its buttons do. Only the runtime's automatic
433
+ "remember what I resolved" write is gated. The cookie records which it was
434
+ (`switcher:<id>` vs a bare id), so the read side and the revocation sweep apply
435
+ the same rule: rejecting personalization deletes an automatic pin but keeps one
436
+ the visitor chose.
437
+
438
+ **Undecided means not consented.** Until a visitor answers the banner, none of
439
+ the personalization cookies are written: pages still render, audiences still
440
+ resolve for the request in hand, experiments still return a variant — nothing is
441
+ persisted, so the visitor keeps seeing default content instead of being tracked.
442
+ A site that wants personalization to persist must place this module (or write
443
+ `kywi_consent` some other way). Adding a new cookie without classifying it in
444
+ that map fails closed, and a core test catches the omission.
445
+
446
+ **The escape hatch.** `theme.personalization.requireConsent` in `kywi.config.ts`
447
+ defaults to `true` — the behaviour above. Set it to `false` only when this site
448
+ collects consent with EXTERNAL tooling (a third-party CMP) that suppresses
449
+ cookies its own way; the gate then treats every category as allowed and the site
450
+ takes responsibility for compliance. Because Next.js edge middleware cannot read
451
+ `kywi.config.ts`, mirror the same value as `KYWI_REQUIRE_CONSENT=false` in the
452
+ environment. **Set both or neither** — `createKywiApiHandler` throws at startup
453
+ when they disagree, so a mismatch fails the deploy rather than silently leaving
454
+ the middleware and the API at odds about whether a visitor may be tracked. On a
455
+ site with no other consent mechanism, `false` is simply the non-compliant
456
+ setting.
457
+
458
+ The banner ships **hidden** (`kywi-consent-pending` on the wrapper) and reveals
459
+ itself after mount only for a visitor with no decision on file — a theme must not
460
+ force it visible, or every returning visitor gets a flash of a consent ask they
461
+ already answered.
462
+
463
+ When consent is required and no page places the module, the API logs a one-time
464
+ warning on its first request: nobody can grant consent, so every personalization
465
+ cookie stays suppressed and audiences, A/B bucketing and UTM attribution serve
466
+ default content to everyone.
467
+
368
468
  ### Which do I reach for? (in order)
369
469
 
370
470
  1. **A built-in already does it** → use it. Read the tables before inventing
@@ -409,9 +509,12 @@ module's *first* prop inline-editable on the page when its type is `text`,
409
509
  directly on the live page, and `props: { variant, headline, … }` cannot.
410
510
 
411
511
  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.
512
+ page", "Case study" — so new pages start from a consistent skeleton. A saved
513
+ layout may link reusable components like any page: the link is indexed, counted
514
+ in the component's usage under "saved layouts", and carried into every page
515
+ stamped out of that layout. Hand-written JSX stays fine for genuinely fixed
516
+ chrome (a bespoke 404, legal boilerplate) — but if marketing will ever want to
517
+ swap a headline, it's a layout page.
415
518
 
416
519
  ### Navigation: menus vs. the site tree
417
520
 
@@ -582,6 +685,39 @@ Four traps, each of which fails silently:
582
685
  Standing rule: **the default arm must be complete on its own.** It is what
583
686
  search engines, answer engines, and every opted-out visitor receive.
584
687
 
688
+ **What actually reaches the visitor (kywi-cms#167).** A published page ships
689
+ only the arm that visitor is served — the other arms of every section- and
690
+ module-level container are stripped on the server, before the document crosses
691
+ into the client renderer (`pruneLayoutToServedArms`, run last in the public
692
+ render path). So view-source shows one arm, not all of them, and the same is
693
+ true of page variants (`applyPageVariant` has always dropped those). Two things
694
+ follow. First, arm copy is not shared with the people it is not for, so an
695
+ unlaunched offer or a segment-specific price is not sitting in the HTML of
696
+ everyone else's page. Second, **the audience RULES still are** — the client
697
+ runtime is handed the active audiences so it can re-evaluate on client signals,
698
+ so how a visitor is classified remains public even though the content each class
699
+ gets is not. Author rules accordingly: they are readable.
700
+
701
+ **One exception, and it is a live one: the retired `variantContainer` MODULE.**
702
+ Pruning covers the two container NODES. It cannot cover the legacy module,
703
+ because that module's arms are not arms of a container at all — they are HTML
704
+ strings in its own props, and it renders every one of them into the DOM by
705
+ design, hiding the losing ones with `display: none` for the client runtime to
706
+ reveal. So a page that still carries one keeps publishing all of its arms in
707
+ plain markup, where `view-source` and "inspect element" both find them
708
+ immediately. Nothing about #167 changed that, and no amount of server-side
709
+ resolution can: the shape is client-swap by construction. If a page you are
710
+ working on has one, converting it is the fix — `kywi upgrade:variant-containers`
711
+ (landing separately, for kywi-cms#172) rewrites existing placements into the
712
+ `moduleVariantContainer` node, which is server-resolved and therefore pruned.
713
+ Until it is converted, treat that module's arms as public, whatever the audience
714
+ binding says.
715
+
716
+ The editor is the deliberate exception — the front-edit overlay and the admin
717
+ layout editor are handed the STORED document, every arm intact, because that is
718
+ what is being edited. If you are writing a host page, keep the two apart: prune
719
+ what you pass to `<KywiLayout>`, and pass the editor the layout you loaded.
720
+
585
721
  ### Transparency: automatic, not something you place
586
722
 
587
723
  Once the client runtime is loaded, every personalized (or opted-out-with-a-
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,23 @@ 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 entry: the cookie-consent category map + gate. The
1059
+ // visitor's decision (written by the \`cookieConsent\` layout module) decides
1060
+ // whether the personalization cookies below may be persisted at all.
1061
+ import { cookieAllowedByHeader } from '@kywi-software/core/audiences/consent'
1062
+
1063
+ /**
1064
+ * The edge mirror of \`theme.personalization.requireConsent\` (kywi-cms#91).
1065
+ * Middleware runs on the edge and must not import \`kywi.config.ts\` (a config is
1066
+ * free to pull in plugins and DB-backed code that cannot run there), so the one
1067
+ * flag that lives in config is mirrored here as an env var.
1068
+ * \`KYWI_REQUIRE_CONSENT=false\` turns the gate off for the two cookies this file
1069
+ * writes. Set it together with the theme flag, never one alone —
1070
+ * createKywiApiHandler THROWS at startup when the two disagree, so a mismatch
1071
+ * fails the deploy instead of silently making the middleware and the API
1072
+ * disagree about whether a visitor is tracked.
1073
+ */
1074
+ const REQUIRE_CONSENT = process.env.KYWI_REQUIRE_CONSENT !== 'false'
903
1075
 
904
1076
  /**
905
1077
  * Server-side auth enforcement + transparent session refresh for /admin and the
@@ -952,21 +1124,34 @@ const VISITOR_MAX_AGE = 60 * 60 * 24 * 365 * 2 // 2 years
952
1124
  * would never fire on the landing page. Everything else passes through.
953
1125
  */
954
1126
  function handlePublicRequest(req: NextRequest): NextResponse {
955
- const existing = req.cookies.get(VISITOR_COOKIE)?.value
1127
+ // Both cookies below are \`personalization\` cookies in core's consent map, so
1128
+ // neither is written OR READ until the visitor accepts. The header bridge is
1129
+ // unaffected — this request still gets a visitor id and still personalizes off
1130
+ // its own URL — so a site with no consent banner still renders, it just serves
1131
+ // default content instead of tracking anyone. Place the \`cookieConsent\`
1132
+ // module to let visitors turn personalization on.
1133
+ const cookieHeader = req.headers.get('cookie')
1134
+ const gate = { requireConsent: REQUIRE_CONSENT }
1135
+ const mayPersonalize = cookieAllowedByHeader(VISITOR_COOKIE, cookieHeader, gate)
1136
+ // Without consent the stored id is IGNORED, not just left un-refreshed: a
1137
+ // visitor who accepted last month and rejected today still carries the cookie,
1138
+ // and reading it would keep them tracked under the identity they just revoked.
1139
+ const existing = mayPersonalize ? req.cookies.get(VISITOR_COOKIE)?.value : undefined
956
1140
  const visitorId = existing ?? crypto.randomUUID()
957
1141
  const headers = new Headers(req.headers)
958
1142
  headers.set('x-kywi-visitor', visitorId)
959
1143
  headers.set('x-kywi-url', req.nextUrl.href)
960
1144
  const res = NextResponse.next({ request: { headers } })
961
- if (!existing) {
1145
+ if (!existing && mayPersonalize) {
962
1146
  res.cookies.set(VISITOR_COOKIE, visitorId, { path: '/', maxAge: VISITOR_MAX_AGE, sameSite: 'lax' })
963
1147
  }
964
1148
  // Persist campaign UTM params so audience matching survives internal
965
1149
  // navigation: core's collectUtm reads this cookie when the URL has no utm_*
966
1150
  // query string. Only written when the request carries utm_* params, so an
967
1151
  // 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)
1152
+ // returns null → the existing cookie is left in place) — and only once the
1153
+ // visitor has consented, which utmCookieValue reads off the header we pass.
1154
+ const utmValue = utmCookieValue(req.nextUrl.href, { cookieHeader, ...gate })
970
1155
  if (utmValue) {
971
1156
  res.cookies.set(UTM_COOKIE, utmValue, { path: '/', maxAge: 60 * 60 * 24 * 30, sameSite: 'lax' })
972
1157
  }
@@ -1161,6 +1346,7 @@ import './site.css'
1161
1346
  import config from '../../kywi.config'
1162
1347
  import { getKywi } from '../../lib/kywi'
1163
1348
  import { SiteNav } from '../../components/site-nav'
1349
+ import { KywiJsLoader } from '../../components/kywi-js-loader'
1164
1350
 
1165
1351
  /**
1166
1352
  * Public site shell. Header + footer carry this project's brand; edit them (and
@@ -1208,6 +1394,13 @@ export default async function SiteLayout({ children }: { children: React.ReactNo
1208
1394
  return (
1209
1395
  <div className="site-shell">
1210
1396
  {themeVars ? <style dangerouslySetInnerHTML={{ __html: themeVars }} /> : null}
1397
+ {/* /kywi.js for the nav's hover-intent/keyboard/viewport-flip enhancement
1398
+ (kywi-cms#114) — independent of personalization, gated on the exact
1399
+ same condition <SiteNav> renders on below (a non-empty header menu),
1400
+ so it loads whenever a \`[data-kywi-nav]\` root actually exists. Shares
1401
+ its dedup with <PersonalizationRuntime> (lib/kywi-js-loader.ts), so a
1402
+ page with both nav AND the client runtime enabled loads it once. */}
1403
+ {headerItems.length > 0 && <KywiJsLoader />}
1211
1404
 
1212
1405
  <header className="site-header">
1213
1406
  <div className="site-header__inner">
@@ -1346,7 +1539,15 @@ import type { Metadata } from 'next'
1346
1539
  import { notFound } from 'next/navigation'
1347
1540
  import { cookies, headers } from 'next/headers'
1348
1541
  import { KywiBody, KywiEditableAttribute, KywiEditableRegion } from '@kywi-software/core/scope-client'
1349
- import { KywiLayout, KywiRegion, AudienceMetaTags, hydrateLayoutFeeds, hydrateLayoutNav, type LayoutDocument } from '@kywi-software/core/layout'
1542
+ import {
1543
+ KywiLayout,
1544
+ KywiRegion,
1545
+ AudienceMetaTags,
1546
+ hydrateLayoutFeeds,
1547
+ hydrateLayoutNav,
1548
+ pruneLayoutToServedArms,
1549
+ type LayoutDocument,
1550
+ } from '@kywi-software/core/layout'
1350
1551
  import { KywiJsonLd } from '@kywi-software/core/scope'
1351
1552
  import { ACCESS_COOKIE, canAccessContent, readSessionClaims } from '@kywi-software/core/host'
1352
1553
  import config from '../../../lib/config'
@@ -1358,6 +1559,7 @@ import {
1358
1559
  resolveSelfIdWidget,
1359
1560
  clientRuntimeEnabled,
1360
1561
  transparencyNoticeEnabled,
1562
+ requireConsentEnabled,
1361
1563
  buildFeedResolver,
1362
1564
  resolveLayoutComponents,
1363
1565
  localeAlternates,
@@ -1490,6 +1692,7 @@ export default async function PublicPage({ params, searchParams }: Params & Sear
1490
1692
  serverSignals={perso.signals}
1491
1693
  selfIdWidget={selfIdWidget}
1492
1694
  transparencyNotice={transparencyNoticeEnabled()}
1695
+ requireConsent={requireConsentEnabled()}
1493
1696
  />
1494
1697
  ) : null}
1495
1698
  </>
@@ -1519,15 +1722,28 @@ export default async function PublicPage({ params, searchParams }: Params & Sear
1519
1722
  // KywiLayout is a client component and the renderer's resolver props are
1520
1723
  // functions, which cannot cross the RSC boundary (#147).
1521
1724
  const hydrated = await resolveLayoutComponents(navHydrated, runtime)
1725
+ // LAST pass before the client boundary (#167): keep ONLY the arm this
1726
+ // visitor is served in every variantContainer / moduleVariantContainer.
1727
+ // KywiLayout is a client component, so anything still on the document here
1728
+ // is serialized into the page's RSC flight payload — arm SELECTION was
1729
+ // always server-side, but the losing arms crossed with it and were readable
1730
+ // in view-source. Pruning uses the same \`perso.personalization\` the
1731
+ // renderer is handed, so the markup is unchanged.
1732
+ //
1733
+ // Order matters: after component resolution (a container that is only a
1734
+ // \`componentId\` has no arms of its own to prune yet) and after the feed/nav
1735
+ // hydration passes. The front-edit overlay below is given the STORED
1736
+ // \`layout\`, never this — an editor needs every arm.
1737
+ const served = pruneLayoutToServedArms(hydrated, perso.personalization)
1522
1738
  content = (
1523
1739
  <article className="page page--layout" data-kywi-content-id={contentId}>
1524
1740
  {head}
1525
1741
  <KywiLayout
1526
- layout={hydrated}
1742
+ layout={served}
1527
1743
  personalization={perso.personalization}
1528
1744
  moduleComponents={moduleComponents}
1529
1745
  >
1530
- {Object.keys(hydrated.regions).map((name) => (
1746
+ {Object.keys(served.regions).map((name) => (
1531
1747
  <KywiRegion key={name} name={name} />
1532
1748
  ))}
1533
1749
  </KywiLayout>
@@ -1624,14 +1840,84 @@ import {
1624
1840
  } from '@kywi-software/core/layout'
1625
1841
  import type { SaveAction } from '@kywi-software/core/admin'
1626
1842
 
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.
1843
+ /**
1844
+ * Injects the admin design system's stylesheet as a plain \`<link>\` tag rather
1845
+ * than an ES \`import\` (kywi-cms#130 follow-up). A dynamic \`import('…/admin/
1846
+ * styles.css')\` still shipped the stylesheet on every public route that
1847
+ * reaches this factory, regardless of the \`next/dynamic\` wrapping around
1848
+ * it. A \`<link>\` created imperatively at runtime has no \`import\` statement
1849
+ * for Next's CSS collector to see, so it never enters the build's CSS graph.
1850
+ *
1851
+ * VERIFIED (in the reference app this template mirrors) against a real
1852
+ * \`next build && next start\`: an anonymous fetch of a real published content
1853
+ * page shows zero OverlayShell/editor.css/admin.css references in any of its
1854
+ * fetched resources. Fetching the SAME page against \`next dev\` still shows
1855
+ * every one of those markers regardless of this fix — \`next dev\` bundles far
1856
+ * more eagerly than a production build, for ALL routes, independent of any
1857
+ * dynamic()/runtime-injection technique. A generated app's own e2e coverage
1858
+ * of this (if any) needs to build for real, not assert against \`next dev\`.
1859
+ *
1860
+ * Two follow-ups from review:
1861
+ * - CACHE-BUSTING: \`/kywi-admin.css\` and \`/kywi-admin.css.version\` are both
1862
+ * synced from the installed \`@kywi-software/core\` package
1863
+ * (scripts/sync-kywi-admin-css.mjs, run via package.json's
1864
+ * \`sync:kywi-admin-css\` on every \`predev\`/\`prebuild\`) — mirroring how
1865
+ * \`public/kywi.js\` is synced from \`@kywi-software/js\`. The version
1866
+ * file's content becomes the \`?v=\` query string below, so a core upgrade
1867
+ * that changes the stylesheet always serves a URL a CDN/browser has never
1868
+ * cached, instead of a stale \`/kywi-admin.css\` response.
1869
+ * - FOUC: returns a Promise that resolves once the tag has actually loaded
1870
+ * (or failed to) — an existing, already-loaded tag resolves immediately;
1871
+ * a fresh or still-loading one resolves on its \`load\`/\`error\` event. The
1872
+ * dynamic() factory below awaits this ALONGSIDE the OverlayShell JS
1873
+ * import, so the editor's first paint never races its own stylesheet.
1874
+ */
1875
+ function ensureAdminStylesheet(): Promise<void> {
1876
+ if (typeof document === 'undefined') return Promise.resolve()
1877
+ const existing = document.querySelector<HTMLLinkElement>('link[data-kywi-admin-css]')
1878
+ if (existing) {
1879
+ // \`.sheet\` is non-null only once the stylesheet has actually parsed —
1880
+ // resolve immediately rather than re-attaching listeners to an event
1881
+ // that already fired.
1882
+ if (existing.sheet) return Promise.resolve()
1883
+ return new Promise((resolve) => {
1884
+ existing.addEventListener('load', () => resolve(), { once: true })
1885
+ existing.addEventListener('error', () => resolve(), { once: true })
1886
+ })
1887
+ }
1888
+ return fetch('/kywi-admin.css.version')
1889
+ .then((res) => (res.ok ? res.text() : ''))
1890
+ .catch(() => '')
1891
+ .then(
1892
+ (version) =>
1893
+ new Promise<void>((resolve) => {
1894
+ const link = document.createElement('link')
1895
+ link.rel = 'stylesheet'
1896
+ const v = version.trim()
1897
+ link.href = v ? \`/kywi-admin.css?v=\${encodeURIComponent(v)}\` : '/kywi-admin.css'
1898
+ link.dataset.kywiAdminCss = 'true'
1899
+ // A load OR error either way lets the caller proceed — a stylesheet
1900
+ // that 404s must never permanently block the editor from opening.
1901
+ link.addEventListener('load', () => resolve(), { once: true })
1902
+ link.addEventListener('error', () => resolve(), { once: true })
1903
+ document.head.appendChild(link)
1904
+ }),
1905
+ )
1906
+ }
1907
+
1908
+ // Lazy-load the full layout editor bundle: the shell's JS (dnd-kit, canvas,
1909
+ // panels) is pulled INSIDE this factory, so it enters the module graph only
1910
+ // when an editor opens the overlay — never in the public browse bundle a
1911
+ // visitor downloads. The stylesheet is handled separately — see
1912
+ // \`ensureAdminStylesheet\` above — since a css import here, even a dynamic
1913
+ // one, does not behave like the JS import right below it. Both are awaited
1914
+ // together so the editor never paints ahead of its own stylesheet (FOUC).
1631
1915
  const OverlayShell = dynamic(
1632
1916
  async () => {
1633
- await import('@kywi-software/core/admin/styles.css')
1634
- const mod = await import('@kywi-software/core/admin')
1917
+ const [, mod] = await Promise.all([
1918
+ ensureAdminStylesheet(),
1919
+ import('@kywi-software/core/admin'),
1920
+ ])
1635
1921
  return mod.OverlayShell
1636
1922
  },
1637
1923
  { ssr: false },
@@ -1804,6 +2090,62 @@ export function KywiFrontEdit({
1804
2090
  `
1805
2091
  }
1806
2092
 
2093
+ /**
2094
+ * Shared `/kywi.js` script-injection primitive (kywi-cms#114). Two independent
2095
+ * triggers can each want the browser bundle on the page: the site layout's nav
2096
+ * (`<KywiJsLoader>`, purely for the hover-intent/keyboard/viewport-flip
2097
+ * enhancement `initNavMenus()` applies unconditionally once the script boots)
2098
+ * and `<PersonalizationRuntime>` (which additionally waits on
2099
+ * `window.Kywi.bootAudienceEngine`). Before this fix only the personalization
2100
+ * runtime ever injected the tag, so a site with `personalization.clientRuntime`
2101
+ * off — or simply no active audiences — never got the nav enhancement either,
2102
+ * even though the nav renders fully without it (kywi-cms#111's JS-off contract)
2103
+ * and would only ever gain from it. `ensureKywiJsScript` is idempotent:
2104
+ * whichever caller mounts first creates the ONE `<script data-kywi-js>` tag,
2105
+ * every other caller finds it already there — so both triggers being true at
2106
+ * once never loads the bundle twice.
2107
+ */
2108
+ function kywiJsLoaderLib() {
2109
+ return `export function ensureKywiJsScript(): HTMLScriptElement | null {
2110
+ if (typeof document === 'undefined') return null
2111
+ const existing = document.querySelector<HTMLScriptElement>('script[data-kywi-js]')
2112
+ if (existing) return existing
2113
+ const script = document.createElement('script')
2114
+ script.src = '/kywi.js'
2115
+ script.async = true
2116
+ script.dataset.kywiJs = 'true'
2117
+ document.body.appendChild(script)
2118
+ return script
2119
+ }
2120
+ `
2121
+ }
2122
+
2123
+ /**
2124
+ * Loads `/kywi.js` for its nav enhancement alone, independent of
2125
+ * personalization (kywi-cms#114). Rendered by the site layout whenever the
2126
+ * resolved header menu is non-empty — the same condition `<SiteNav>` itself
2127
+ * gates on before it renders a `[data-kywi-nav]` root — so this mounts exactly
2128
+ * when there is a nav for `initNavMenus()` to enhance. Does nothing beyond
2129
+ * ensuring the script tag exists: `Kywi.boot()` runs `initNavMenus()`
2130
+ * unconditionally as part of its own bootstrap once the script loads, and
2131
+ * never touches personalization on its own.
2132
+ */
2133
+ function kywiJsLoaderComponent() {
2134
+ return `'use client'
2135
+
2136
+ import { useEffect } from 'react'
2137
+ import { ensureKywiJsScript } from '../lib/kywi-js-loader'
2138
+
2139
+ export function KywiJsLoader() {
2140
+ useEffect(() => {
2141
+ ensureKywiJsScript()
2142
+ }, [])
2143
+
2144
+ return null
2145
+ }
2146
+ `
2147
+ }
2148
+
1807
2149
  /**
1808
2150
  * Client personalization runtime (kywi-cms#50). The SERVER already resolved and
1809
2151
  * rendered the correct audience/experiment variant and set the ids in <head>;
@@ -1814,6 +2156,12 @@ export function KywiFrontEdit({
1814
2156
  * stays dependency-free by default: drop the built @kywi-software/js bundle at
1815
2157
  * public/kywi.js to enable it (see README → "Personalization"). Without it the
1816
2158
  * server-rendered variant is exactly what every visitor sees.
2159
+ *
2160
+ * kywi-cms#114: `/kywi.js` may already be on the page independent of this
2161
+ * component — the site layout's `<KywiJsLoader>` loads it purely for nav
2162
+ * enhancement whenever the site has a nav. `ensureKywiJsScript` (shared with
2163
+ * that component, lib/kywi-js-loader.ts) is idempotent, so this never injects
2164
+ * a second copy.
1817
2165
  */
1818
2166
  function personalizationRuntime() {
1819
2167
  return `'use client'
@@ -1821,6 +2169,7 @@ function personalizationRuntime() {
1821
2169
  import React, { useEffect } from 'react'
1822
2170
  import type { Audience, VisitorSignals } from '@kywi-software/core/audiences/types'
1823
2171
  import type { PublicSelfIdWidget } from '../lib/site'
2172
+ import { ensureKywiJsScript } from '../lib/kywi-js-loader'
1824
2173
 
1825
2174
  /* eslint-disable @typescript-eslint/no-explicit-any */
1826
2175
  declare global {
@@ -1843,15 +2192,27 @@ interface PersonalizationRuntimeProps {
1843
2192
  * deliberately through a \`personalizationBadge\` module. Defaults to true.
1844
2193
  */
1845
2194
  transparencyNotice?: boolean
2195
+ /**
2196
+ * \`personalization.requireConsent\` — false turns the client's consent gate
2197
+ * off for a site that collects consent with external tooling. Defaults to
2198
+ * true, so the resolved audience is only remembered for a consenting visitor
2199
+ * (kywi-cms#91).
2200
+ */
2201
+ requireConsent?: boolean
1846
2202
  }
1847
2203
 
1848
2204
  /**
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.
2205
+ * Ensures /kywi.js is on the page, then boots the audience data-layer.
2206
+ * bootAudienceEngine reads the opt-out cookie itself and resolves opted-out
2207
+ * visitors to the default experience, so opt-out is respected end to end.
2208
+ * Rendered only when theme.personalization.clientRuntime is on.
2209
+ *
2210
+ * kywi-cms#114: the script tag may already be on the page independent of this
2211
+ * component — the site layout's <KywiJsLoader> loads it purely for nav
2212
+ * enhancement whenever the site has a nav. ensureKywiJsScript (shared with
2213
+ * that component) is idempotent, so this never injects a second copy.
1853
2214
  */
1854
- export function PersonalizationRuntime({ audiences, serverSignals, selfIdWidget, transparencyNotice }: PersonalizationRuntimeProps) {
2215
+ export function PersonalizationRuntime({ audiences, serverSignals, selfIdWidget, transparencyNotice, requireConsent }: PersonalizationRuntimeProps) {
1855
2216
  useEffect(() => {
1856
2217
  let cancelled = false
1857
2218
  const ready = () => typeof window.Kywi?.bootAudienceEngine === 'function'
@@ -1868,6 +2229,7 @@ export function PersonalizationRuntime({ audiences, serverSignals, selfIdWidget,
1868
2229
  ? { selfIdWidget: { ...selfIdWidget, audiences, currentPath } }
1869
2230
  : {}),
1870
2231
  ...(transparencyNotice === false ? { transparencyNotice: false } : {}),
2232
+ ...(requireConsent === false ? { requireConsent: false } : {}),
1871
2233
  })
1872
2234
  .catch(() => {
1873
2235
  /* client personalization is best-effort; the server already rendered defaults */
@@ -1888,18 +2250,14 @@ export function PersonalizationRuntime({ audiences, serverSignals, selfIdWidget,
1888
2250
  }, 60)
1889
2251
  }
1890
2252
 
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
- }
2253
+ // kywi-cms#114: the script tag itself may already be on the page — the
2254
+ // site layout's <KywiJsLoader> injects it independently, purely for nav
2255
+ // enhancement, whenever the site has a nav. \`ensureKywiJsScript\` is the
2256
+ // shared dedup primitive: it returns the existing tag if one is already
2257
+ // there, so this never creates a second one.
2258
+ const script = ensureKywiJsScript()
2259
+ if (ready()) boot()
2260
+ else script?.addEventListener('load', whenReady)
1903
2261
 
1904
2262
  return () => {
1905
2263
  cancelled = true
@@ -1962,7 +2320,7 @@ function apiRoute() {
1962
2320
  return `import { NextRequest, NextResponse } from 'next/server'
1963
2321
  import { getKywiHandler } from '../../../../lib/kywi'
1964
2322
  import {
1965
- TOKEN_ISSUING_PATHS,
2323
+ isTokenIssuingPath,
1966
2324
  parseSessionTokens,
1967
2325
  setAccessCookie,
1968
2326
  setRefreshCookie,
@@ -1993,7 +2351,7 @@ async function handleRequest(
1993
2351
  return out
1994
2352
  }
1995
2353
 
1996
- if (TOKEN_ISSUING_PATHS.has(path) && res.ok) {
2354
+ if (isTokenIssuingPath(path) && res.ok) {
1997
2355
  const bodyText = await res.text()
1998
2356
  const { accessToken, refreshToken } = parseSessionTokens(bodyText)
1999
2357
  const out = new NextResponse(bodyText, { status: res.status, headers: res.headers })
@@ -2243,6 +2601,14 @@ or read-only visitor gets no toolbar and no extra client JS. Custom
2243
2601
  editor's insert palette and are fully editable in place — the props rail and
2244
2602
  inline text editing work exactly as they do in the admin's Layout tab.
2245
2603
 
2604
+ The admin stylesheet (\`public/kywi-admin.css\`) is synced automatically from
2605
+ your installed \`@kywi-software/core\` version on every \`dev\`/\`build\` (via
2606
+ \`predev\`/\`prebuild\` — see \`scripts/sync-kywi-admin-css.mjs\`), not on
2607
+ \`install\`, and is gitignored — it is a build artifact of whichever core
2608
+ version you have, not project source. Run \`pnpm sync:kywi-admin-css\`
2609
+ yourself if you ever need it without a full dev/build cycle (e.g. before
2610
+ \`next start\` against an already-built \`.next\`).
2611
+
2246
2612
  ## Personalization, A/B testing & self-ID
2247
2613
 
2248
2614
  Audiences, experiments and the self-ID widget you configure in the admin resolve
@@ -2252,7 +2618,16 @@ Audiences, experiments and the self-ID widget you configure in the admin resolve
2252
2618
  audience \`variantContainer\` render for the winning audience.
2253
2619
  - **A/B experiments** — each visitor is assigned a variant arm deterministically
2254
2620
  and the exposure is recorded. The assignment is stable because the
2255
- \`middleware.ts\` gives every visitor a persistent \`kywi_visitor\` id.
2621
+ \`middleware.ts\` gives every visitor a persistent \`kywi_visitor\` id — once
2622
+ they have consented. \`kywi_visitor\`, \`kywi_utm\`, \`kywi_signals\` and
2623
+ \`kywi_audience\` are \`personalization\` cookies, and the engine will not write
2624
+ any of them until the visitor accepts. Place the \`cookieConsent\` module to
2625
+ give them that choice; until then pages render their default content and
2626
+ nobody is tracked. If this site collects consent with EXTERNAL
2627
+ tooling instead, set \`personalization: { requireConsent: false }\` on your
2628
+ theme in \`kywi.config.ts\` **and** \`KYWI_REQUIRE_CONSENT=false\` in the
2629
+ environment (the edge middleware cannot read the config) — that hands
2630
+ compliance to your tooling and restores the un-gated cookie behaviour.
2256
2631
  - **Preview links** — an admin preview link sets \`kywi_preview_init\` and the page
2257
2632
  honours it, so you can preview an audience's experience.
2258
2633
 
@@ -2263,6 +2638,15 @@ dropping the built \`@kywi-software/js\` browser bundle at \`public/kywi.js\`; t
2263
2638
  generated \`components/personalization-runtime.tsx\` loads it best-effort. Without
2264
2639
  it, the server-rendered variant is what every visitor sees.
2265
2640
 
2641
+ The same \`public/kywi.js\` bundle also drives the header nav's hover-intent,
2642
+ Escape/arrow-key and viewport-edge-flip enhancement — independent of
2643
+ personalization. \`components/kywi-js-loader.tsx\` loads it whenever the site has
2644
+ a nav, whether or not \`clientRuntime\` is on, so the nav gets that polish on
2645
+ every site once the bundle is in place; the nav renders and works fully without
2646
+ it either way. (\`public/kywi-admin.css\`, the front-of-site editor's stylesheet,
2647
+ is a separate file synced automatically on \`dev\`/\`build\` — see "Front-of-site
2648
+ editor" above — unlike \`kywi.js\`, which you place yourself.)
2649
+
2266
2650
  ## Locales
2267
2651
 
2268
2652
  Set \`locales\` on your site in \`kywi.config.ts\` (e.g. \`locales: ['en', 'es']\`),
@@ -2361,7 +2745,7 @@ lib/config.ts single import path for kywi.config.ts
2361
2745
  app/api/v1/[...kywi]/route.ts the versioned API (delegates to core)
2362
2746
  app/admin/[[...admin]]/page.tsx mounts the FULL core admin (all surfaces) at /admin
2363
2747
  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)'}
2748
+ 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
2749
  \`\`\`
2366
2750
  `
2367
2751
  }
@@ -2757,10 +3141,19 @@ export function buildFileSet(answers) {
2757
3141
  // Public-render helpers: path/locale resolution, feed + component resolvers,
2758
3142
  // personalization + experiments.
2759
3143
  files['lib/site.ts'] = libSite()
3144
+ // Shared /kywi.js script-injection primitive (kywi-cms#114) — the site
3145
+ // layout's nav loader and the personalization runtime below both use it,
3146
+ // so loading the bundle for either reason never double-loads it.
3147
+ files['lib/kywi-js-loader.ts'] = kywiJsLoaderLib()
3148
+ files['components/kywi-js-loader.tsx'] = kywiJsLoaderComponent()
2760
3149
  // Optional client personalization runtime (self-ID widget, live re-eval). #50
2761
3150
  files['components/personalization-runtime.tsx'] = personalizationRuntime()
2762
3151
  // Front-of-site edit overlay (?kywi-edit=1), mounted by the page above.
2763
3152
  files['app/(site)/kywi-front-edit.tsx'] = frontEditOverlay()
3153
+ // Syncs the admin stylesheet + a cache-busting version marker into
3154
+ // public/ on every predev/prebuild — see the doc comment on
3155
+ // syncKywiAdminCssScript and packageJson's syncScripts.
3156
+ files['scripts/sync-kywi-admin-css.mjs'] = syncKywiAdminCssScript()
2764
3157
  } else {
2765
3158
  // headless + decoupled: no public rendering.
2766
3159
  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.13.0",
3
+ "version": "0.15.0",
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",