@consenti/ui 0.2.0 → 0.4.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.
Files changed (42) hide show
  1. package/README.md +512 -138
  2. package/dist/angular.d.cts +610 -151
  3. package/dist/angular.d.ts +610 -151
  4. package/dist/angular.js +1 -1
  5. package/dist/angular.js.map +1 -1
  6. package/dist/angular.mjs +1 -1
  7. package/dist/angular.mjs.map +1 -1
  8. package/dist/chunk-3EXCD24H.mjs +2 -0
  9. package/dist/{chunk-K2UN3O67.mjs.map → chunk-3EXCD24H.mjs.map} +1 -1
  10. package/dist/chunk-RDE7CM6T.mjs +2 -0
  11. package/dist/chunk-RDE7CM6T.mjs.map +1 -0
  12. package/dist/http-AQVVUCX6.mjs +2 -0
  13. package/dist/index.css +1 -1
  14. package/dist/index.d.ts +971 -125
  15. package/dist/index.mjs +1 -1
  16. package/dist/index.mjs.map +1 -1
  17. package/dist/index.umd.js +1 -1
  18. package/dist/index.umd.js.map +1 -1
  19. package/dist/profiles-5ZLWWN2L.mjs +2 -0
  20. package/dist/profiles-5ZLWWN2L.mjs.map +1 -0
  21. package/dist/react.d.cts +589 -129
  22. package/dist/react.d.ts +589 -129
  23. package/dist/react.js +1 -1
  24. package/dist/react.js.map +1 -1
  25. package/dist/react.mjs +1 -1
  26. package/dist/react.mjs.map +1 -1
  27. package/dist/testing.d.cts +1 -1
  28. package/dist/testing.d.ts +1 -1
  29. package/dist/testing.js +1 -1
  30. package/dist/testing.js.map +1 -1
  31. package/dist/testing.mjs +1 -1
  32. package/dist/testing.mjs.map +1 -1
  33. package/dist/vue.d.cts +562 -79
  34. package/dist/vue.d.ts +562 -79
  35. package/dist/vue.js +1 -1
  36. package/dist/vue.js.map +1 -1
  37. package/dist/vue.mjs +1 -1
  38. package/dist/vue.mjs.map +1 -1
  39. package/package.json +13 -8
  40. package/dist/chunk-K2UN3O67.mjs +0 -2
  41. package/dist/http-UU2NIW6M.mjs +0 -2
  42. /package/dist/{http-UU2NIW6M.mjs.map → http-AQVVUCX6.mjs.map} +0 -0
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @consenti/ui
2
2
 
3
- Zero-dependency, GDPR-compliant cookie consent widget for any web framework.
3
+ GDPR-style cookie consent widget for any web framework — zero required runtime dependencies.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/@consenti/ui.svg)](https://www.npmjs.com/package/@consenti/ui)
6
6
  [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](../../LICENSE)
@@ -12,12 +12,15 @@ Zero-dependency, GDPR-compliant cookie consent widget for any web framework.
12
12
 
13
13
  - **Zero runtime dependencies** — browser built-ins only (`fetch`, `crypto.subtle`, `BroadcastChannel`, `CustomEvent`, `document.cookie`, `localStorage`)
14
14
  - **Fully standalone or API-connected** — works offline with pre-built profiles, or connects to `@consenti/api` for dashboard-managed profiles
15
- - **Multi-regulation** — GDPR, CCPA, CPRA, LGPD, DPDPA, PIPL, and more via 8 built-in compliance groups
15
+ - **Multi-regulation** — Maintained: GDPR, CCPA, CPRA, LGPD. Partial: TCF v2.3 (real binary encoding requires the `@consenti/api` backend) — only relevant if you monetize through programmatic/RTB ad exchanges. In development: DPDPA. More via 8 built-in compliance groups — see [ECOSYSTEM.md](../../ECOSYSTEM.md) for per-regulation status
16
+
17
+ > **Registration is only needed for programmatic ad monetization.** For first-party analytics/marketing consent, Consenti is fully compliant with zero external registration. Consenti implements the IAB TCF/GPP technical specs but is **not** itself a registered CMP; if you do need TCF/GPP, you must register your own `cmpId` with IAB Europe/MSPA independently before going live — see the [TCF & GPP Registration Guide](https://consenti.dev/docs/compliance/tcf-and-gpp-registration).
16
18
  - **Automatic geo-resolution** — detects visitor jurisdiction client-side (timezone + language) or server-side (`/resolve-profile`)
17
19
  - **GPC aware** — Global Privacy Control signal detection with `'honor'` / `'strict'` / `'ignore'` modes per profile
18
20
  - **GTM / Google Consent Mode v2** — built-in `dataLayer` integration
19
21
  - **SSR-safe** — all browser API access is guarded; `new ConsentiSetup()` during SSR is a silent no-op
20
22
  - **Fully typed** — ships TypeScript definitions; no `@types` needed
23
+ - **Companion CLI**: [`@consenti/scanner`](../scanner) crawls a site under none/reject-all/accept-all consent states and reports undeclared third-party trackers
21
24
 
22
25
  ---
23
26
 
@@ -35,17 +38,15 @@ ES2020+ · Chrome 80+ · Firefox 74+ · Safari 13.1+
35
38
  npm install @consenti/ui
36
39
  ```
37
40
 
38
- Then import CSS and the class:
41
+ Then import the class:
39
42
 
40
43
  ```ts
41
44
  import { ConsentiSetup } from '@consenti/ui'
42
- import '@consenti/ui/dist/index.css'
43
45
  ```
44
46
 
45
47
  ### CDN / UMD (no build step)
46
48
 
47
49
  ```html
48
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@consenti/ui/dist/index.css" />
49
50
  <script src="https://cdn.jsdelivr.net/npm/@consenti/ui/dist/index.umd.js"></script>
50
51
  <script>
51
52
  const { ConsentiSetup } = ConsentiUI
@@ -63,12 +64,15 @@ import '@consenti/ui/dist/index.css'
63
64
  ```
64
65
 
65
66
  ### CSS options
67
+ Choose any approach to load stylesheet, by default it will be auto injected
66
68
 
67
69
  | Approach | How |
68
70
  |-----------------------------------|-----------------------------------------------------------------------|
69
- | Import from package (recommended) | `import '@consenti/ui/dist/index.css'` |
71
+ | Auto Injection (recommended) | by default consenti will inject css |
72
+ | Manual injection (avoids FOUC) | `import '@consenti/ui/dist/index.css'` in your bundler entry or code and set `disableCssTemplate: true;` |
70
73
  | Skip all CSS (bring your own) | Set `core.disableCssTemplate: true` — no styles injected |
71
74
  | CSS custom properties only | Import CSS, then override `--consenti-*` variables in your stylesheet |
75
+ | core.theme config in ConsentiSetup| Provide css variables for your stylesheet in the setup as js object |
72
76
 
73
77
  ---
74
78
 
@@ -107,21 +111,30 @@ A consent banner appears on first visit. Everything below is optional.
107
111
 
108
112
  ```ts
109
113
  import { ConsentiSetup } from '@consenti/ui'
110
- import '@consenti/ui/dist/index.css'
111
114
 
112
115
  const widget = new ConsentiSetup({
113
116
  // ── Core (optional) ──────────────────────────────────────────────────────────
114
117
  core: {
115
118
  tenantId: 'acme', // identifies your tenant's profiles; default: 'default'
116
119
  locale: 'en', // BCP 47; 'auto' = navigator.language; default: 'auto'
117
- autoHonorGPC: true, // explicit operator override: false | true | 'strict'
118
- // overrides profile.gpcMode when set
120
+ dir: 'auto', // 'ltr' | 'rtl' | 'auto'; 'auto' derives from locale via
121
+ // Intl.Locale(...).getTextInfo().direction (falls back to a
122
+ // hand-maintained ar/he/fa/ur/ps/sd/ug/yi → rtl list on
123
+ // engines without getTextInfo() support); default: 'auto'
119
124
  storage: 'cookie', // 'cookie' | 'localStorage'; default: 'cookie'
125
+ cookieName: 'consenti_data', // cookie/localStorage key name; default: 'consenti_data'
126
+ // not switched automatically by detected region — set
127
+ // explicitly if you want a different name (e.g. 'euconsent-v2',
128
+ // only meaningful if you're using the spec-correct binary TCF
129
+ // encoder — @consenti/api + @iabtechlabtcf/core — since that
130
+ // name implies IAB's binary format, not Consenti's own encoding)
120
131
  cookieDomains: '.example.com', // comma-separated; first entry used as Domain attribute
121
132
  cookieSigningKey: 'min-32-char-secret', // HMAC-SHA256 signing; implicit from presence
122
133
  allowReceipt: true, // allow consent receipt download; default: false
123
134
  disableCssTemplate: false, // skip all style injection; default: false
124
135
  userId: 'server-assigned-uuid', // authenticated users — enables cross-device sync
136
+ // prefer widget.getUserId()/setUserId() (or the
137
+ // consenti:listener:identify event) to change it after init
125
138
 
126
139
  // Pre-built profile fallback
127
140
  usePrebuiltProfiles: 'all', // 'all' (default) | ['opt-in', 'opt-out', ...] (non-empty)
@@ -131,24 +144,31 @@ const widget = new ConsentiSetup({
131
144
  console: ['error'], // log levels emitted: 'info' | 'log' | 'warning' | 'error'
132
145
  // default: ['error'] only — no noise in production
133
146
 
147
+ // Field names mirror their --consenti-* CSS variable literally (--consenti-color-primary
148
+ // → colorPrimary). Every field is optional and maps 1:1 to one CSS custom property — see
149
+ // apps/ui/src/styles/_variables.scss for the full set of ~33 variables. Common ones:
134
150
  theme: {
135
- bgColor: '#ffffff',
136
- textColor: '#1a1a1a',
137
- primaryColor: '#1565c0',
138
- primaryTextColor: '#ffffff',
139
- secondaryColor: '#f0f4f8',
140
- secondaryTextColor: '#1a3460',
141
- borderColor: '#e2e8f0',
142
- accentColor: '#d32f2f',
143
- accentTextColor: '#ffffff',
151
+ colorBg: '#ffffff',
152
+ colorText: '#1a1a1a',
153
+ colorTextMuted: '#949dab',
154
+ colorPrimary: '#1565c0',
155
+ colorPrimaryText: '#ffffff',
156
+ colorSecondary: '#f0f4f8',
157
+ colorSecondaryText: '#1a3460',
158
+ colorBorder: '#e2e8f0',
159
+ colorAccent: '#d32f2f',
160
+ colorAccentText: '#ffffff',
144
161
  fontFamily: 'system-ui, sans-serif',
145
162
  fontSizeBase: '14px',
146
163
  fontSizeHeading: '18px',
147
164
  fontSizeMultiplier: '1',
148
165
  borderRadius: '8px',
149
- buttonBorderRadius: '4px',
166
+ borderRadiusBtn: '4px',
150
167
  toggleBgOn: '#1565c0',
151
168
  toggleBgOff: '#cccccc',
169
+ // ...plus colorSecondaryBorder, colorOverlay, fontFamilyMono, fontWeightHeading,
170
+ // lineHeight, spacingXs/Sm/Md/Lg, shadow, toggleBgPartial, toggleKnob,
171
+ // toggleWidth/Height, zBanner/Overlay/Modal
152
172
  },
153
173
  },
154
174
 
@@ -166,11 +186,28 @@ const widget = new ConsentiSetup({
166
186
  // WidgetCountryResolverFn → custom async function: () => Promise<{ country, region, confidence }>
167
187
  geoDataProvider: 'default',
168
188
 
169
- // Age gate (optional)
170
- ageGate: {
189
+ // Only meaningful when api.enabled = false (ignored, with a warning, otherwise — the server
190
+ // resolves the group in that mode). Only overrides country→group mapping; country/region
191
+ // detection above always uses the embedded geo data.
192
+ // 'default' → embedded map (default) | a URL string (fetched; the browser's own HTTP cache
193
+ // honors whatever Cache-Control/ETag the response sends) | an inline ComplianceMapData object
194
+ complianceMap: 'default',
195
+
196
+ // TCF (IAB Transparency & Consent Framework) client stub (optional) — see "TCF" section below
197
+ tcf: {
198
+ enabled: false,
199
+ cmpId: 0, // must match the cmpId configured on the backend (TcfConfig)
200
+ cmpVersion: 1,
201
+ },
202
+
203
+ // GPP (IAB Global Privacy Platform, US National section) client stub (optional) — see "GPP" section below
204
+ gpp: {
171
205
  enabled: false,
172
- minimumAge: 13,
173
- requireParentalConsent: false,
206
+ cmpId: 0, // must match the cmpId configured on the backend (GppConfig)
207
+ cmpVersion: 1,
208
+ mspaCoveredTransaction: false, // no honest default — required
209
+ mspaOptOutOptionMode: 0, // 0 = not applicable | 1 = yes | 2 = no
210
+ mspaServiceProviderMode: 0,
174
211
  },
175
212
  },
176
213
 
@@ -211,6 +248,30 @@ const widget = new ConsentiSetup({
211
248
  })
212
249
  ```
213
250
 
251
+ ### `profileOverride` — deep-merge and delete semantics
252
+
253
+ `profileOverride` (and `widget.setProfile()` at runtime) is deep-merged onto the resolved profile
254
+ — server-fetched, pre-built, or locally registered — before anything renders. Merge rules:
255
+
256
+ - Omitting a key (or setting it to `undefined`) leaves the resolved profile's value untouched.
257
+ - An object value merges recursively, key by key — you only need to specify what differs.
258
+ - An array value replaces/merges by index against the base array.
259
+ - Setting a key to `null` **deletes it** from the merged result (JSON Merge Patch / RFC 7396
260
+ semantics). This is the way to remove a single entry from a keyed map — a cookie category, a
261
+ parameter — without having to know or repeat the rest of that map's contents:
262
+
263
+ ```ts
264
+ profileOverride: {
265
+ preferenceModal: {
266
+ categories: { marketing: null }, // removes the 'marketing' category entirely
267
+ },
268
+ },
269
+ ```
270
+
271
+ `{ marketing: undefined }` or `{ marketing: {} }` do **not** delete the entry — the former is a
272
+ no-op (same as omitting the key), the latter merges an empty object onto the existing category,
273
+ changing nothing. Only an explicit `null` removes it.
274
+
214
275
  ---
215
276
 
216
277
  ## Compliance Groups
@@ -260,7 +321,7 @@ new ConsentiSetup({
260
321
  | **1A** | `api.enabled = false`, `compliance.type = 'auto'` | Timezone + language → group → pre-built profile |
261
322
  | **1B** | `api.enabled = false`, `compliance.type = ComplianceGroupId` | Direct pre-built profile load |
262
323
  | **2A** | `api.enabled = true`, `compliance.type = 'auto'` | `GET /resolve-profile?tz&lang&locale&tenantId` → `filePath` → static JSON |
263
- | **2B** | `api.enabled = true`, `compliance.type = ComplianceGroupId` | `GET /profiles/:tenantId/:group/:locale.json` |
324
+ | **2B** | `api.enabled = true`, `compliance.type = ComplianceGroupId` | `GET /profiles/:tenantId/:group/:locale` |
264
325
  | **Local** | `compliance.type = localKey` + registered `ConsentiProfile` | Locally registered profile |
265
326
  | **Fallback** | Any fetch failure | Pre-built profile → `DEFAULT_PROFILE` |
266
327
 
@@ -276,25 +337,158 @@ Set `api.trustDomain: true` to bypass this check (useful for localhost / dev env
276
337
 
277
338
  ## GPC — Global Privacy Control
278
339
 
279
- GPC mode is set per-profile on the server (`gpcMode: 'honor' | 'strict' | 'ignore'`). Widget config (`core.autoHonorGPC`) overrides the profile value when explicitly set.
340
+ GPC mode is set per-profile on the server (`gpcMode: 'honor' | 'strict' | 'ignore'`). Widget config profileOverrides overrides the profile value when explicitly set.
341
+
342
+ `gpcMode` only applies once the widget itself has loaded and initialized. If a tag-loading script
343
+ sits earlier in `<head>` (GTM/gtag.js, or another script), it can still fire before that. Use
344
+ `buildSyncGpcSnippet()` to close that gap — it returns a tiny, dependency-free `<script>` string to
345
+ place first in `<head>`, ahead of everything else, that checks `navigator.globalPrivacyControl`
346
+ synchronously and pre-freezes Google Consent Mode v2 to denied:
280
347
 
281
348
  ```ts
282
- // Respect profile's gpcMode setting (default)
283
- new ConsentiSetup({ compliance: { type: 'opt-in' } })
349
+ import { buildSyncGpcSnippet } from '@consenti/ui'
350
+
351
+ buildSyncGpcSnippet() // default dataLayer name
352
+ buildSyncGpcSnippet({ dataLayerName: 'myDataLayer' }) // custom dataLayer name
353
+ ```
284
354
 
285
- // Force honor GPC regardless of profile setting
286
- new ConsentiSetup({ core: { autoHonorGPC: true } })
355
+ Safe to run before Consenti's own `gtm` config pushes its defaults on init — same values, same
356
+ stub-queue, so the second push is a no-op rather than a conflict. Full walkthrough with the
357
+ `<head>` ordering: [Advanced Configuration → GPC](https://consenti.dev/docs/ui/advanced-configuration).
287
358
 
288
- // Force strict GPC — pre-deny silently, no banner
289
- new ConsentiSetup({ core: { autoHonorGPC: 'strict' } })
359
+ ---
360
+
361
+ ## Age Gate
362
+
363
+ Age gate is a **per-profile, per-locale** setting — not a widget-config option. GDPR Article 8's
364
+ consent age varies 13–16 by EU member state and DPDPA has its own child-data age rules, so one
365
+ global age doesn't fit a multi-region deployment. Enable it on a profile in the dashboard's Profile
366
+ Editor Step 1 (minimum age, optional parental-consent requirement), and author the modal's
367
+ heading/body/button text per locale in the Main Banner content step — the widget reads it off the
368
+ resolved profile (`profile.ageGate`/`profile.ageGateModal`) automatically, no widget config needed.
369
+ For a standalone/local profile (`registerProfile()`), set `ageGate`/`ageGateModal` directly on the
370
+ `EmbeddedProfile`/`EmbeddedTranslations` you register.
371
+
372
+ When enabled, it blocks every other consent UI (banner, GPC, CCPA opt-out — all of it waits behind
373
+ the age gate on first visit):
374
+
375
+ - **Confirmed** (visitor is old enough) → the normal banner/GPC/CCPA flow proceeds exactly as if
376
+ the age gate didn't exist, and every consent submission from then on carries `ageVerified: true`.
377
+ - **Declined, `requireParentalConsent: false`** → a deny-all consent is submitted immediately
378
+ (mandatory/strictly-necessary cookies still granted), no banner shown, `ageVerified: false`.
379
+ - **Declined, `requireParentalConsent: true`** → same deny-all submission, plus a
380
+ `parentalConsentToken` is requested from the backend (`POST /consent/:visitorId/parental-consent-request`,
381
+ signed with the backend's `compliance.dataSigningHash` when configured — falls back to a
382
+ client-generated, unsigned token if `api.enabled` is false) and a
383
+ `consenti:parentalConsentRequired` event fires carrying it. There's no email-sending pipeline
384
+ built into this package — the event (or your backend's `eventBus.on('consent.parentalConsentRequired', ...)`
385
+ listener, see the `@consenti/api` README's "Parental consent" section) is the hook for wiring
386
+ your own out-of-band verification process.
387
+
388
+ When the parent later completes that process (often on a fresh page with no live widget instance —
389
+ their own device, not the child's), resolve the token with the standalone `resolveParentalConsent`
390
+ export instead of a `ConsentiSetup` method:
391
+
392
+ ```ts
393
+ import { resolveParentalConsent } from '@consenti/ui'
394
+
395
+ const { visitorId, profileId } = await resolveParentalConsent(token, { baseUrl: 'https://consent.example.com' })
396
+ // dispatches consenti:parentalConsentResolved on window — a page with a live widget instance
397
+ // can listen for it and react (e.g. re-show the banner for the real consent choice); what
398
+ // "resolved" should actually do to consent state is host-defined, same as the request side.
290
399
  ```
291
400
 
292
- | `core.autoHonorGPC` | Effect |
293
- |---|---|
294
- | `undefined` (default) | Profile's `gpcMode` takes effect |
295
- | `false` | GPC signal always ignored |
296
- | `true` | `'honor'` — pre-deny GPC cookies; show GPC banner variant |
297
- | `'strict'` | Pre-deny silently — no banner shown |
401
+ This is plumbing, not verifiable parental consent by itself — see the `@consenti/api` README for
402
+ the full caveats (token replay isn't prevented, staying stateless is a deliberate tradeoff).
403
+
404
+ The prompt only asks once per visitor per profile — it doesn't re-appear once any consent record
405
+ exists (same "already decided" check the banner itself uses).
406
+
407
+ ---
408
+
409
+ ## TCF — IAB Transparency & Consent Framework
410
+
411
+ Set `compliance.tcf.enabled: true` to install `window.__tcfapi`, the standard IAB entry point
412
+ third-party ad-tech scripts call to read consent (`ping`, `getTCData`, `addEventListener`,
413
+ `removeEventListener`):
414
+
415
+ ```ts
416
+ new ConsentiSetup({
417
+ compliance: {
418
+ tcf: { enabled: true, cmpId: 280, cmpVersion: 1 }, // cmpId must match the backend's TcfConfig
419
+ },
420
+ })
421
+ ```
422
+
423
+ The stub follows the standard queue-command convention, so it's safe regardless of load order
424
+ relative to third-party scripts that call `__tcfapi` before Consenti has initialized. Only one CMP
425
+ may own `window.__tcfapi` per page — if it's already set (a real CMP, or another `ConsentiSetup`
426
+ instance on a multi-profile page), this stub does not overwrite it.
427
+
428
+ This is a simplified implementation: `tcString` is a base64url-encoded JSON payload (the same
429
+ default format the backend's `tcfString` uses), not the full IAB binary bitfield encoding, and
430
+ vendor-list metadata (`gvlVersion`, `publisherCC`) is operator-supplied via `TcfWidgetConfig`
431
+ rather than fetched client-side (the GVL is multi-megabyte; IAB policy expects CMPs to cache their
432
+ own copy server-side rather than have every visitor's browser re-fetch it). `gdprApplies` is
433
+ derived automatically from the resolved profile's compliance group.
434
+
435
+ For spec-correct binary TC-string encoding, run with the `@consenti/api` backend, set
436
+ `compliance.tcf.publisherCC`, and install the optional `@iabtechlabtcf/core` peer dependency
437
+ (the actively-maintained IAB Tech Lab package — not `iabtcf-core`, which doesn't exist on npm).
438
+ See the [TCF & GPP Registration Guide](https://consenti.dev/docs/compliance/tcf-and-gpp-registration) for details.
439
+
440
+ ---
441
+
442
+ ## GPP — IAB Global Privacy Platform (US National)
443
+
444
+ Set `compliance.gpp.enabled: true` to install `window.__gpp`, the standard IAB entry point
445
+ scripts call for US state-privacy-law signals (`ping`, `addEventListener`, `removeEventListener`,
446
+ `getSection`, `hasSection`) — `usnat` (US National) section only:
447
+
448
+ ```ts
449
+ new ConsentiSetup({
450
+ compliance: {
451
+ gpp: {
452
+ enabled: true,
453
+ cmpId: 280, // must match the backend's GppConfig
454
+ cmpVersion: 1,
455
+ mspaCoveredTransaction: true, // whether this deployment falls under MSPA signatory obligations
456
+ mspaOptOutOptionMode: 1, // 0 = not applicable | 1 = yes | 2 = no
457
+ mspaServiceProviderMode: 0,
458
+ },
459
+ },
460
+ })
461
+ ```
462
+
463
+ Sale/sharing opt-out flags are derived automatically from the resolved profile's cookies tagged
464
+ `cpraCategory: 'sale'` / `'sharing'` and the visitor's actual consent — no separate config needed.
465
+ Like TCF, only one CMP may own `window.__gpp` per page; this stub does not overwrite an existing one,
466
+ and follows the standard stub-queue convention so load order relative to third-party scripts doesn't
467
+ matter.
468
+
469
+ Unlike TCF, GPP's US National section needs no Global Vendor List, so both this stub's `gppString`
470
+ and the backend's `gppString` use the same real, spec-correct encoder when the optional
471
+ `@iabgpp/cmpapi` peer dependency is installed on `@consenti/api` — there's no "simplified fallback"
472
+ format the way TCF has. Without it, `gppString` falls back to a base64url-encoded JSON payload.
473
+
474
+ ---
475
+
476
+ ## RTL / Text Direction
477
+
478
+ `core.dir` controls the banner/modal's reading direction:
479
+
480
+ ```ts
481
+ new ConsentiSetup({ core: { locale: 'ar', dir: 'auto' } }) // → rtl, derived from locale
482
+ new ConsentiSetup({ core: { dir: 'rtl' } }) // explicit, independent of locale
483
+ ```
484
+
485
+ `'auto'` (the default) maps a known RTL-language prefix (`ar`, `he`, `fa`, `ur`, `ps`, `sd`, `ug`,
486
+ `yi`) to `rtl`; anything else is `ltr`. The `dir` attribute is set on the widget's root element, so
487
+ browser-native mirroring (flex layout, `text-align: start/end`) and the widget's own `[dir="rtl"]`
488
+ CSS overrides (toggle switches, locale switcher, close button) apply automatically — no separate
489
+ RTL stylesheet needed. Explicit banner/modal position variants (`left-bottom`, `right-bottom`,
490
+ modal `left`/`right` slide-in) are **not** mirrored — those are screen positions you chose
491
+ deliberately, independent of text direction.
298
492
 
299
493
  ---
300
494
 
@@ -327,12 +521,12 @@ import { ConsentiProfile, ConsentiSetup } from '@consenti/ui'
327
521
  const profile = new ConsentiProfile({
328
522
  defaultLocale: 'en',
329
523
  complianceGroup: 'opt-in',
330
- cookies: [
331
- { id: 'necessary', mandatory: true, expiry: 365 },
332
- { id: 'analytics', listenGpc: true, expiry: 365 },
333
- { id: 'marketing', listenGpc: true, expiry: 365, cpraCategory: 'sale' },
524
+ cookies: {
525
+ necessary: { purpose: 'necessary', listenGpc: false },
526
+ analytics: { purpose: 'analytics', listenGpc: true },
527
+ marketing: { purpose: 'marketing', listenGpc: true, cpraCategory: 'sale' },
334
528
  // cpraCategory: 'sale' | 'sharing' | 'sensitive' — used by CPRA group
335
- ],
529
+ },
336
530
  translations: {
337
531
  en: {
338
532
  mainBanner: {
@@ -343,13 +537,14 @@ const profile = new ConsentiProfile({
343
537
  heading: 'We value your privacy',
344
538
  headingTag: 'h2', // HTML tag for the heading; default 'h2'
345
539
  htmlText: 'We use cookies to improve your experience.',
346
- buttons: [
347
- { text: 'Accept All', style: 'primary', action: 'custom', cookies: '*' },
540
+ buttons: {
541
+ // key = machine id, rendered as the button's DOM id: `consenti-btn-{id}`, for targeting a specific button
348
542
  // cookies: '*' = grant all | '!' = deny all | ['id1','id2'] = grant specific
349
- { text: 'Reject Optional', style: 'secondary', action: 'custom', cookies: '!' },
350
- { text: 'Customize', style: 'secondary', action: 'manage' },
351
- { text: 'Privacy Policy', style: 'text', action: 'link', url: '/privacy' },
352
- ],
543
+ 'accept-all': { text: 'Accept All', style: 'primary', action: 'custom', cookies: '*' },
544
+ 'reject-optional': { text: 'Reject Optional', style: 'primary', action: 'custom', cookies: '!' }, // same weight as accept-all — CNIL requires reject not be visually subordinate
545
+ customize: { text: 'Customize', style: 'secondary', action: 'manage' },
546
+ 'privacy-policy': { text: 'Privacy Policy', style: 'text', action: 'link', url: '/privacy' },
547
+ },
353
548
  },
354
549
  gpcBanner: { // shown instead of mainBanner when GPC detected
355
550
  position: 'bottom',
@@ -357,10 +552,10 @@ const profile = new ConsentiProfile({
357
552
  headingTag: 'h2',
358
553
  showLocaleSwitcher: false,
359
554
  htmlText: "Your browser's GPC signal was detected. Ad cookies have been pre-denied.",
360
- buttons: [
361
- { text: 'Understood', style: 'primary', action: 'custom', cookies: '!' },
362
- { text: 'Customize', style: 'secondary', action: 'manage' },
363
- ],
555
+ buttons: {
556
+ understood: { text: 'Understood', style: 'primary', action: 'custom', cookies: '!' },
557
+ customize: { text: 'Customize', style: 'secondary', action: 'manage' },
558
+ },
364
559
  },
365
560
  preferenceModal: {
366
561
  heading: 'Cookie Preferences',
@@ -373,34 +568,32 @@ const profile = new ConsentiProfile({
373
568
  persistent: false, // true = cannot dismiss by clicking outside
374
569
  overlayOpacity: 50,
375
570
  mobileFullScreenBreakpoint: 576,
376
- buttons: [
377
- { text: 'Accept All', style: 'primary', action: 'custom', cookies: '*' },
378
- { text: 'Save Preferences', style: 'primary', action: 'submit' },
379
- { text: 'Reject Optional', style: 'text', action: 'custom', cookies: '!' },
380
- ],
381
- categories: [
382
- {
383
- id: 'cat-necessary',
571
+ buttons: {
572
+ 'accept-all': { text: 'Accept All', style: 'primary', action: 'custom', cookies: '*' },
573
+ 'save-preferences': { text: 'Save Preferences', style: 'primary', action: 'submit' },
574
+ 'reject-optional': { text: 'Reject Optional', style: 'text', action: 'custom', cookies: '!' },
575
+ },
576
+ categories: {
577
+ necessary: {
384
578
  heading: 'Strictly Necessary',
385
579
  headingTag: 'h3',
386
580
  htmlText: 'Required for the site to function. <strong>Cannot be disabled.</strong>',
387
- mandatory: true,
581
+ legalBasis: 'mandatory',
388
582
  cookies: ['necessary'],
389
583
  },
390
- {
391
- id: 'cat-analytics',
584
+ analytics: {
392
585
  heading: 'Analytics',
393
586
  htmlText: 'Helps us understand how visitors use the site.',
394
- type: 'consent', // 'consent' | 'legitimate_interest'
587
+ legalBasis: 'consent', // 'mandatory' | 'consent' | 'legitimate_interest'
395
588
  cookies: ['analytics'],
396
589
  },
397
- {
398
- id: 'cat-marketing',
590
+ marketing: {
399
591
  heading: 'Marketing',
400
592
  htmlText: 'Used to personalise ads and measure campaign performance.',
593
+ legalBasis: 'consent',
401
594
  cookies: ['marketing'],
402
595
  },
403
- ],
596
+ },
404
597
  },
405
598
  },
406
599
  fr: {
@@ -435,18 +628,77 @@ The widget calls `GET /resolve-profile?tz&lang&locale&tenantId`, receives the pr
435
628
 
436
629
  ## Cookie Format
437
630
 
438
- Consent is persisted as a single cookie named `consenti_{userId}_{profileId}`.
631
+ Consent is persisted as a single cookie named `consenti_data` with a compact JSON value:
439
632
 
633
+ ```json
634
+ {"s":1,"v":2,"i":"5huf-…","u":"","t":1751692400,"g":0,"p":0,"c":{"analytics_storage":"g","ad_storage":"d"}}
440
635
  ```
441
- t:{unixTimestamp}::{cookieId}:{status}|{cookieId}:{status}|…[::sig:{hmac}]
636
+
637
+ | Field | Type | Meaning |
638
+ |---|---|---|
639
+ | `s` | number | Profile ID |
640
+ | `v` | number | Profile version |
641
+ | `i` | string | Per-submission UUID (consent receipt ID) |
642
+ | `u` | string | Logged-in user ID (`""` for anonymous) |
643
+ | `t` | number | Unix timestamp of consent |
644
+ | `g` | 0 \| 1 | GPC signal detected |
645
+ | `p` | number | Consent source: 0 = user click, 1 = widget method |
646
+ | `c` | object | Consent map: cookie ID → `"g"` (granted), `"o"` (objected), `"d"` (denied) |
647
+
648
+ When the widget is upgraded from an older version, any existing `consenti_{profileId}` cookie is automatically read, migrated to the new format, and deleted — no consent loss occurs.
649
+
650
+ The consent cookie lifetime equals the shortest `cookie.expiry` value across all profile cookies (in days, converted to `max-age` seconds). When that cookie expires the browser deletes it, the widget sees no consent, and re-shows the banner.
651
+
652
+ ---
653
+
654
+ ## Display Features
655
+
656
+ ### Footer Metadata
657
+
658
+ When `showFooterMetadata: true` is set in the resolved profile, a metadata strip is injected inside the banner and modal after the main content. It shows the visitor's Consent ID (truncated UUID), Consent Date, Profile Version, and a "Privacy Settings" button that opens the preference modal.
659
+
660
+ Configured in the dashboard (ProfileEditor → Step 1) or via `profileOverride`:
661
+
662
+ ```ts
663
+ new ConsentiSetup({
664
+ compliance: { type: 'opt-in' },
665
+ profileOverride: { showFooterMetadata: true },
666
+ })
442
667
  ```
443
668
 
444
- Example:
669
+ ### Enhanced Accessibility
670
+
671
+ When `enhanceAccessibility: true` is set in the resolved profile, the class `.consenti--enhanced-a11y` is added to the root element. This applies:
672
+
673
+ - Minimum 44 × 44 px hit area on all buttons (WCAG 2.1 SC 2.5.5)
674
+ - 3 px solid focus ring (`outline-offset: 2px`) on all focusable elements
675
+ - No impact on default styling — opt-in only
676
+
677
+ ```ts
678
+ profileOverride: { enhanceAccessibility: true }
445
679
  ```
446
- t:1782133054::analytics:granted|marketing:denied|necessary:granted::sig:abc123
680
+
681
+ ### Responsive Button Stacking (`stackButtonsOnBreakpoint`)
682
+
683
+ Set `mainBanner.stackButtonsOnBreakpoint` (and/or `gpcBanner.stackButtonsOnBreakpoint`) to a pixel width. Below that breakpoint the banner's buttons stack vertically and stretch to full width — useful for narrow mobile screens where 3+ buttons overflow.
684
+
685
+ ```ts
686
+ profileOverride: {
687
+ mainBanner: { stackButtonsOnBreakpoint: 576 }, // px; 0 or undefined = disabled
688
+ }
447
689
  ```
448
690
 
449
- The consent cookie lifetime equals the shortest `cookie.expiry` value across all profile cookies (in days, converted to `max-age` seconds). When that cookie expires the browser deletes it, the widget sees no consent, and re-shows the banner.
691
+ A scoped `<style>` element containing a single `@media (max-width: Npx)` rule is injected into the root element — no external stylesheet required, and it's removed on `destroy()`.
692
+
693
+ ### Modal Focus Trap (`trapFocus`)
694
+
695
+ When `preferenceModal.trapFocus: true`, keyboard Tab / Shift+Tab navigation is confined within the consent modal while it is open. `Escape` closes the modal and returns focus to the triggering element.
696
+
697
+ ```ts
698
+ profileOverride: {
699
+ preferenceModal: { trapFocus: true },
700
+ }
701
+ ```
450
702
 
451
703
  ---
452
704
 
@@ -472,7 +724,13 @@ const widget = new ConsentiSetup({ compliance: { type: 'opt-in' } })
472
724
  // Consent state
473
725
  widget.hasConsent() // boolean — true if valid consent record exists
474
726
  widget.getConsent() // Record<string, 'granted'|'denied'|'objected'> | null
475
- widget.getGTMConsent() // consent in GTM / Google Consent Mode v2 format
727
+ widget.getConsent('google-gtm') // Google Consent Mode v2 format
728
+ widget.getConsent('category') // { necessary, functional, preferences, analytics, marketing }
729
+ widget.getConsent('adobe') // { analytics, target, manager, optimizer }
730
+ widget.getConsent('meta') // { pixel, api, plugins, facebookLogin }
731
+ widget.getConsent('microsoft-clarity') // { session, heatmaps, performance }
732
+ widget.getConsent('twilio-segment') // { identify, page, track, group, alias }
733
+ widget.getGTMConsent() // @deprecated — use getConsent('google-gtm')
476
734
  widget.getConsentDate() // Date | false — time of last submission
477
735
  widget.isCookieGranted('analytics') // boolean — true if that cookie is 'granted'
478
736
  widget.isCookieGranted('analytics', true)// 'granted' | 'denied' | 'objected' | false
@@ -502,11 +760,19 @@ widget.deleteConsent() // delete consent record (cookie + backend if e
502
760
  widget.reConsent() // delete consent and re-show banner
503
761
  widget.destroy() // unmount DOM elements and remove all event listeners
504
762
 
763
+ // Logged-in user identity
764
+ widget.getUserId() // string | null — current application user ID
765
+ widget.setUserId('user-42') // reconsents (by default) if it differs from the stored cookie's user
766
+ widget.setUserId(null) // clear identity (e.g. logout)
767
+ widget.setUserId('user-42', false) // update identity without reconsenting
768
+ // Or dispatch from anywhere (e.g. right after your own login flow completes):
769
+ // window.dispatchEvent(new CustomEvent('consenti:listener:identify', { detail: { userId: 'user-42' } }))
770
+
505
771
  // Runtime configuration
506
772
  widget.setDarkMode() // toggle dark mode
507
773
  widget.setDarkMode(true) // force dark on
508
774
  widget.setDarkMode(false) // force light
509
- widget.setTheme({ primaryColor: '#ff0000' }) // hot-swap CSS tokens (merges into current theme)
775
+ widget.setTheme({ colorPrimary: '#ff0000' }) // hot-swap CSS tokens (merges into current theme)
510
776
  widget.setConfig({ darkMode: true }) // deep-merge partial config (no re-init)
511
777
  widget.setProfile({ mainBanner: { heading: 'Updated' } }) // re-render UI with new profile data
512
778
 
@@ -575,7 +841,7 @@ await widget.submitConsent({
575
841
 
576
842
  // Or use the convenience methods:
577
843
  await widget.grantAll() // accept all
578
- await widget.denyAll() // reject all non-mandatory
844
+ await widget.denyAll() // Reject Optional non-mandatory
579
845
  ```
580
846
 
581
847
  ---
@@ -591,6 +857,15 @@ Custom DOM events fire on `window` at every lifecycle step. All are prefixed `co
591
857
  | `consenti:modalVisibility` | Preference modal shows or hides |
592
858
  | `consenti:consentBeingSubmitted` | User clicked a consent button (before API call) |
593
859
  | `consenti:consentSubmitted` | Consent saved (cookie written + API call if configured) |
860
+ | `consenti:parentalConsentRequired` | Age gate declined with `requireParentalConsent: true` — carries `parentalConsentToken` |
861
+
862
+ The events above are **outbound** (widget → host). `consenti:listener:*` is the opposite
863
+ direction — **inbound** (host → widget), dispatched by your own code to tell the widget
864
+ something:
865
+
866
+ | Event | Dispatch when |
867
+ |--------------------------------|----------------------------------------------------------|
868
+ | `consenti:listener:identify` | Your own login/logout flow completes — `detail: { userId: string \| null, reConsent?: boolean }`, equivalent to calling `widget.setUserId(userId, reConsent)` |
594
869
 
595
870
  **Recommended:** use `widget.on()` / `widget.off()` for typed subscriptions (the `consenti:` prefix is optional):
596
871
 
@@ -612,39 +887,64 @@ window.addEventListener('consenti:consentSubmitted', (e: Event) => {
612
887
 
613
888
  ## GTM / Google Consent Mode v2
614
889
 
890
+ Setting `utils.gtm` to any object (even `{}`) turns on real Consent Mode signalling — no gtag.js
891
+ needs to already be on the page, Consenti defines the standard stub itself.
892
+
615
893
  ```ts
616
894
  new ConsentiSetup({
617
895
  compliance: { type: 'opt-in' },
618
896
  utils: {
619
897
  gtm: {
620
- containerId: 'GTM-XXXXXX',
621
- urlPassthrough: true, // cookieless conversion modelling
622
- adsDataRedaction: true, // redact ad pings when consent denied
898
+ containerId: 'GTM-XXXXXX', // omit if you load GTM/gtag.js yourself
899
+ urlPassthrough: true, // cookieless conversion modelling
900
+ adsDataRedaction: true, // redact ad pings when consent denied
623
901
  },
624
902
  },
625
903
  })
626
904
  ```
627
905
 
628
- Consenti pushes to `window.dataLayer` in the standard Google Consent Mode v2 format:
906
+ **`GtmConfig` options:**
907
+
908
+ | Option | Type | Default | Description |
909
+ |---|---|---|---|
910
+ | `containerId` | `string` | `undefined` | GTM container ID, e.g. `'GTM-XXXXXX'`. When set, Consenti injects the GTM library itself — omit if you already load GTM/gtag.js separately (Consent Mode signalling works either way). |
911
+ | `dataLayer` | `string` | `'dataLayer'` | Name of the dataLayer array on `window`. Override only for a custom variable name. |
912
+ | `verbose` | `boolean` | `false` | When `true`, additionally mirrors every `consenti:*` event onto the dataLayer as a generic `{ event, content }` push — for custom, non-Consent-Mode GTM triggers. |
913
+ | `events` | `string[]` | `[]` | Only relevant when `verbose: true` — narrows which event names get mirrored. Empty means all. Has no effect on the core `gtag('consent', …)` calls, which always fire. |
914
+ | `urlPassthrough` | `boolean` | `false` | Calls `gtag('set', 'url_passthrough', true)` alongside every consent update. |
915
+ | `adsDataRedaction` | `boolean` | `false` | Calls `gtag('set', 'ads_data_redaction', true)` when `ad_storage` is denied. |
916
+
917
+ Consenti calls the real `gtag()` consent API — via the standard stub-queue pattern, so it works
918
+ whether your own gtag.js/GTM snippet loads before or after Consenti:
629
919
 
630
920
  ```js
631
921
  // On initialisation (default denied state)
632
- dataLayer.push({ 'event': 'consent_default', 'analytics_storage': 'denied', ... })
922
+ gtag('consent', 'default', { analytics_storage: 'denied', ... })
633
923
 
634
924
  // On submission
635
- dataLayer.push({ 'event': 'consent_update', 'analytics_storage': 'granted', 'ad_storage': 'denied', ... })
925
+ gtag('consent', 'update', { analytics_storage: 'granted', ad_storage: 'denied', ... })
926
+
927
+ // Plus, when configured:
928
+ gtag('set', 'url_passthrough', true)
929
+ gtag('set', 'ads_data_redaction', true) // true when ad_storage is denied
636
930
  ```
637
931
 
638
- `getGTMConsent()` returns consent in GTM format:
932
+ Set `utils.gtm.verbose: true` to additionally mirror every `consenti:*` event onto the dataLayer
933
+ as a generic `{ event, content }` push — off by default.
934
+
935
+ `getConsent('google-gtm')` returns consent in Google Consent Mode v2 format. `getGTMConsent()` is a deprecated alias for the same call.
639
936
 
640
937
  ```ts
641
- widget.getGTMConsent()
938
+ widget.getConsent('google-gtm')
642
939
  // {
643
940
  // analytics_storage: 'granted',
644
941
  // ad_storage: 'denied',
645
942
  // ad_user_data: 'denied',
646
943
  // ad_personalization: 'denied',
647
944
  // functionality_storage: 'granted',
945
+ // security_storage: 'granted',
946
+ // ads_data_redaction: 'true',
947
+ // url_passthrough: 'false',
648
948
  // }
649
949
  ```
650
950
 
@@ -667,20 +967,64 @@ new ConsentScript({
667
967
  })
668
968
  ```
669
969
 
670
- ### CookieTrigger — open banner or modal from any element
970
+ ### ConsentAction — run a callback on a parameter's grant status
971
+
972
+ For integrations that expose their own `sdk.optIn()`/`sdk.optOut()` API (Segment, Mixpanel,
973
+ Amplitude, Sentry, etc.) instead of a `<script>` tag — use `ConsentScript` for that case instead.
974
+
975
+ ```ts
976
+ import { ConsentAction } from '@consenti/ui'
977
+
978
+ new ConsentAction({
979
+ id: 'analytics_storage',
980
+ widget,
981
+ onGrant: () => analyticsSdk.optIn(),
982
+ onDeny: () => analyticsSdk.optOut(),
983
+ // bind: true (default) — re-fires on every future consent change
984
+ })
985
+ ```
986
+
987
+ ### CategoryScript / CategoryAction — gate on a whole category
988
+
989
+ Same as `ConsentScript`/`ConsentAction`, but keyed on a preference-modal category instead of a
990
+ single parameter — "granted" only when *every* parameter in the category is `'granted'`.
671
991
 
672
992
  ```ts
673
- import { CookieTrigger } from '@consenti/ui'
993
+ import { CategoryScript, CategoryAction } from '@consenti/ui'
994
+
995
+ new CategoryScript({ categoryId: 'marketing', widget, src: 'https://example.com/ad-pixel.js' })
996
+
997
+ new CategoryAction({
998
+ id: 'marketing',
999
+ widget,
1000
+ onGrant: () => adSdk.enableAll(),
1001
+ onDeny: () => adSdk.disableAll(),
1002
+ })
1003
+ ```
1004
+
1005
+ ### scanConsentScripts — declarative `data-*` script gating
1006
+
1007
+ Zero-JS integration path: mark `<script>` tags with `data-consenti-consent-script` /
1008
+ `data-consenti-category-script` and Consenti scans and wires them up automatically at the end of
1009
+ every `init()` cycle (call `scanConsentScripts(widget)` again manually after adding tags at runtime).
1010
+
1011
+ ```html
1012
+ <script type="text/plain" data-consenti-consent-script="analytics_storage" src="https://www.googletagmanager.com/gtag/js?id=G-XXXXX"></script>
1013
+ <script type="text/plain" data-consenti-category-script="marketing" src="https://example.com/pixel.js"></script>
1014
+ <script type="text/plain" data-consenti-consent-script="ad_storage" data-consenti-bind="false">/* inline snippet, evaluated once */</script>
1015
+ ```
1016
+
1017
+ ### BannerTrigger — open banner or modal from any element
1018
+
1019
+ ```ts
1020
+ import { BannerTrigger } from '@consenti/ui'
674
1021
 
675
1022
  // Attach to existing element
676
- new CookieTrigger({ selector: '#cookie-settings', action: 'modal' })
1023
+ new BannerTrigger({ widget, el: '#cookie-settings', action: 'modal' })
677
1024
 
678
1025
  // Auto-create a button
679
- new CookieTrigger({
680
- action: 'modal',
681
- label: 'Manage Cookies',
682
- appendTo: document.querySelector('#footer'),
683
- })
1026
+ const trigger = new BannerTrigger({ widget, action: 'modal', label: 'Manage Cookies' })
1027
+ document.querySelector('#footer')?.appendChild(trigger.getElement())
684
1028
  ```
685
1029
 
686
1030
  ---
@@ -694,41 +1038,50 @@ Override any token in your own stylesheet:
694
1038
  ```css
695
1039
  :root {
696
1040
  /* Colors */
697
- --consenti-primary-text: #1a3460;
698
- --consenti-secondary-text: #555555;
699
- --consenti-primary-bg: #ffffff;
700
- --consenti-secondary-bg: #f5f5f5;
701
- --consenti-overlay-bg: rgba(0, 0, 0, 0.5);
702
-
703
- /* Buttons */
704
- --consenti-btn-primary-bg: #1565c0;
705
- --consenti-btn-primary-text: #ffffff;
706
- --consenti-btn-secondary-bg: #f5f5f5;
707
- --consenti-btn-secondary-text: #1a3460;
708
- --consenti-btn-radius: 6px;
709
- --consenti-btn-padding: 0.5rem 1.25rem;
710
- --consenti-btn-font-weight: 600;
711
-
712
- /* Banner */
713
- --consenti-banner-shadow: 0 -4px 24px rgba(0, 0, 0, 0.12);
714
- --consenti-banner-radius: 0;
715
- --consenti-banner-padding: 1.5rem;
716
- --consenti-banner-max-width: 960px;
717
-
718
- /* Modal */
719
- --consenti-modal-radius: 12px;
720
- --consenti-modal-shadow: 0 8px 32px rgba(0, 0, 0, 0.24);
721
- --consenti-modal-width: 560px;
722
- --consenti-modal-max-height: 80vh;
723
-
724
- /* Toggle */
725
- --consenti-toggle-on: #1565c0;
726
- --consenti-toggle-off: #ccc;
1041
+ --consenti-color-bg: #ffffff;
1042
+ --consenti-color-text: #1a2e4a;
1043
+ --consenti-color-text-muted: #949dab;
1044
+ --consenti-color-primary: #04111f;
1045
+ --consenti-color-primary-text: #ffffff;
1046
+ --consenti-color-secondary: #f0f4f8;
1047
+ --consenti-color-secondary-text: #1a2e4a;
1048
+ --consenti-color-border: #dbe4ee;
1049
+ --consenti-color-secondary-border: #1a2e4a;
1050
+ --consenti-color-overlay: #04111f;
1051
+ --consenti-color-accent: #d32f2f;
1052
+ --consenti-color-accent-text: #ffffff;
727
1053
 
728
1054
  /* Typography */
729
- --consenti-font-size-base: 14px;
730
- --consenti-font-size-mult: 1;
731
1055
  --consenti-font-family: system-ui, -apple-system, sans-serif;
1056
+ --consenti-font-family-mono: ui-monospace, monospace;
1057
+ --consenti-font-size-base: 14px;
1058
+ --consenti-font-size-heading: 16px;
1059
+ --consenti-font-weight-heading: 600;
1060
+ --consenti-line-height: 1.5;
1061
+
1062
+ /* Spacing */
1063
+ --consenti-spacing-xs: 5px;
1064
+ --consenti-spacing-sm: 8px;
1065
+ --consenti-spacing-md: 16px;
1066
+ --consenti-spacing-lg: 24px;
1067
+
1068
+ /* Shape */
1069
+ --consenti-border-radius: 8px;
1070
+ --consenti-border-radius-btn: 0;
1071
+ --consenti-shadow: 0 4px 24px rgba(21, 101, 192, 0.14);
1072
+
1073
+ /* Toggle (preference modal) */
1074
+ --consenti-toggle-bg-on: #43a047;
1075
+ --consenti-toggle-bg-partial: #97c098;
1076
+ --consenti-toggle-bg-off: #9ca3af;
1077
+ --consenti-toggle-knob: #ffffff;
1078
+ --consenti-toggle-width: 52px;
1079
+ --consenti-toggle-height: 28px;
1080
+
1081
+ /* Stacking */
1082
+ --consenti-z-banner: 9999;
1083
+ --consenti-z-overlay: 9998;
1084
+ --consenti-z-modal: 10000;
732
1085
  }
733
1086
  ```
734
1087
 
@@ -739,10 +1092,10 @@ new ConsentiSetup({
739
1092
  compliance: { type: 'opt-in' },
740
1093
  core: {
741
1094
  theme: {
742
- primaryColor: '#7c3aed', // purple brand
743
- primaryTextColor: '#ffffff',
1095
+ colorPrimary: '#7c3aed', // purple brand
1096
+ colorPrimaryText: '#ffffff',
744
1097
  borderRadius: '12px',
745
- buttonBorderRadius: '999px', // pill buttons
1098
+ borderRadiusBtn: '999px', // pill buttons
746
1099
  fontFamily: 'Inter, sans-serif',
747
1100
  },
748
1101
  },
@@ -807,7 +1160,6 @@ new ConsentiSetup({
807
1160
 
808
1161
  import { useEffect } from 'react'
809
1162
  import { ConsentiSetup } from '@consenti/ui'
810
- import '@consenti/ui/dist/index.css'
811
1163
 
812
1164
  export function ConsentSetup() {
813
1165
  useEffect(() => {
@@ -860,7 +1212,6 @@ export default function RootLayout({ children }: { children: React.ReactNode })
860
1212
 
861
1213
  import { useEffect, useRef } from 'react'
862
1214
  import type { ConsentiSetup as WidgetType } from '@consenti/ui'
863
- import '@consenti/ui/dist/index.css'
864
1215
 
865
1216
  export function ConsentSetup() {
866
1217
  const widgetRef = useRef<WidgetType | null>(null)
@@ -870,7 +1221,7 @@ export function ConsentSetup() {
870
1221
  import('@consenti/ui').then(({ ConsentiSetup }) => {
871
1222
  widget = new ConsentiSetup({
872
1223
  api: { enabled: true, baseUrl: process.env.NEXT_PUBLIC_API_URL },
873
- core: { autoHonorGPC: true },
1224
+ core: { },
874
1225
  })
875
1226
  widgetRef.current = widget
876
1227
  })
@@ -891,7 +1242,6 @@ let widget: unknown = null
891
1242
 
892
1243
  onMounted(async () => {
893
1244
  const { ConsentiSetup } = await import('@consenti/ui')
894
- await import('@consenti/ui/dist/index.css')
895
1245
  widget = new ConsentiSetup({ compliance: { type: 'opt-in' } })
896
1246
  })
897
1247
 
@@ -930,15 +1280,18 @@ export class ConsentService implements OnDestroy {
930
1280
  }
931
1281
  ```
932
1282
 
933
- Or use the built-in Angular service:
1283
+ Or use the built-in Angular integration (`setConsentiWidget()` once at app root, then
1284
+ `injectConsent()` in any component/service):
934
1285
 
935
1286
  ```ts
936
- import { ConsentiService } from '@consenti/ui/angular'
1287
+ import { injectConsent } from '@consenti/ui/angular'
937
1288
 
938
1289
  @Component({ /* ... */ })
939
1290
  export class MyComponent {
940
- consent = inject(ConsentiService)
1291
+ consent = injectConsent()
941
1292
  // consent.hasConsent() | consent.showModal() | consent.getConsent()
1293
+ // consent.onConsentChange(cb) / onBannerChange(cb) / onModalChange(cb) — call in
1294
+ // ngOnInit, unsubscribe with the returned function in ngOnDestroy
942
1295
  }
943
1296
  ```
944
1297
 
@@ -946,7 +1299,6 @@ export class MyComponent {
946
1299
 
947
1300
  ```js
948
1301
  import { ConsentiSetup } from '@consenti/ui'
949
- import '@consenti/ui/dist/index.css'
950
1302
 
951
1303
  const widget = new ConsentiSetup({ compliance: { type: 'opt-in' }, core: { locale: 'en' } })
952
1304
 
@@ -1037,9 +1389,9 @@ new ConsentiSetup({
1037
1389
  api: { enabled: true },
1038
1390
  })
1039
1391
 
1040
- // GPC strict mode + GTM
1392
+ // GTM
1041
1393
  new ConsentiSetup({
1042
- core: { autoHonorGPC: 'strict' },
1394
+ core: {},
1043
1395
  utils: { gtm: { containerId: 'GTM-XXXXXX', adsDataRedaction: true } },
1044
1396
  })
1045
1397
 
@@ -1047,6 +1399,17 @@ new ConsentiSetup({
1047
1399
  new ConsentiSetup({
1048
1400
  core: { console: ['error', 'warning', 'info', 'log'] },
1049
1401
  })
1402
+
1403
+ // Arabic site — RTL derived automatically from locale
1404
+ new ConsentiSetup({ core: { locale: 'ar' } })
1405
+
1406
+ // COPPA — block under-13s, require parental consent: set on the profile in the dashboard
1407
+ // (Profile Editor Step 1 → Enable age gate → minimum age 13, require parental consent), not here.
1408
+
1409
+ // TCF — install the __tcfapi stub for ad-tech scripts (cmpId must match the backend's tcf config)
1410
+ new ConsentiSetup({
1411
+ compliance: { tcf: { enabled: true, cmpId: 280, cmpVersion: 1 } },
1412
+ })
1050
1413
  ```
1051
1414
 
1052
1415
  ---
@@ -1061,11 +1424,11 @@ import type {
1061
1424
  CoreConfig, // core section
1062
1425
  ApiConfig, // api section
1063
1426
  ComplianceWidgetConfig, // compliance section
1064
- AgeGateWidgetConfig, // compliance.ageGate section
1065
1427
  WidgetCountryResolverFn, // custom geo resolver function type
1066
1428
  UtilsConfig, // utils section
1067
1429
  GtmConfig, // utils.gtm section
1068
1430
  ThemeConfig, // core.theme section
1431
+ IdentifyEventDetail, // consenti:listener:identify event detail
1069
1432
  ConsentValue, // 'granted' | 'denied' | 'objected'
1070
1433
  ConsentiProfile, // local profile class
1071
1434
  NonEmptyArray, // [T, ...T[]] — used by usePrebuiltProfiles
@@ -1081,7 +1444,18 @@ The following `CoreConfig` fields were **removed** in v0.1.x:
1081
1444
  | `core.profileId` | Use `compliance.type` with a compliance group or local profile key |
1082
1445
  | `core.regulation` | Use `compliance.type` — compliance group is derived server-side or from pre-built profiles |
1083
1446
  | `core.signCookies` | Implicit: set `core.cookieSigningKey` to enable signing; no separate flag needed |
1084
- | `core.privacyPolicyUrl` | Add a link button directly in the profile banner/modal `buttons` array with `action: 'link'` |
1447
+ | `core.privacyPolicyUrl` | Add a link button directly in the profile banner/modal `buttons` map with `action: 'link'` |
1448
+
1449
+ ---
1450
+
1451
+ ## Security notes
1452
+
1453
+ **`core.cookieSigningKey` is spoofable in standalone mode.** Without a `@consenti/api` backend,
1454
+ this key has nowhere to live but the shipped browser bundle — so anyone can read it out of your JS
1455
+ and re-sign a forged cookie with it. It still catches accidental tampering (e.g. a stale value from
1456
+ an older config), but it is not tamper-*proof* against a motivated attacker. If you need consent
1457
+ records that hold up as evidence, sign them server-side instead with `compliance.dataSigningHash`
1458
+ (`@consenti/api`), where the key never reaches the browser.
1085
1459
 
1086
1460
  ---
1087
1461