@consenti/ui 0.3.0 → 0.5.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.
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
- dir: 'auto', // 'ltr' | 'rtl' | 'auto'; 'auto' derives from locale
118
- // (ar/he/fa/ur/ps/sd/ug/yi → rtl); default: 'auto'
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,12 +186,12 @@ const widget = new ConsentiSetup({
166
186
  // WidgetCountryResolverFn → custom async function: () => Promise<{ country, region, confidence }>
167
187
  geoDataProvider: 'default',
168
188
 
169
- // Age gate (optional) — see "Age Gate" section below
170
- ageGate: {
171
- enabled: false,
172
- minimumAge: 13,
173
- requireParentalConsent: false,
174
- },
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',
175
195
 
176
196
  // TCF (IAB Transparency & Consent Framework) client stub (optional) — see "TCF" section below
177
197
  tcf: {
@@ -179,6 +199,16 @@ const widget = new ConsentiSetup({
179
199
  cmpId: 0, // must match the cmpId configured on the backend (TcfConfig)
180
200
  cmpVersion: 1,
181
201
  },
202
+
203
+ // GPP (IAB Global Privacy Platform, US National section) client stub (optional) — see "GPP" section below
204
+ gpp: {
205
+ enabled: 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,
211
+ },
182
212
  },
183
213
 
184
214
  // ── Mount point (optional) ────────────────────────────────────────────────
@@ -242,6 +272,47 @@ profileOverride: {
242
272
  no-op (same as omitting the key), the latter merges an empty object onto the existing category,
243
273
  changing nothing. Only an explicit `null` removes it.
244
274
 
275
+ #### Replacing a whole map: `'*': null`
276
+
277
+ Removing entries one by one doesn't scale when you want to *replace* a map: every built-in
278
+ profile uses different button ids (`reject-optional`, `save-choices`, `manage-preferences`, …),
279
+ and there are three `buttons` maps (`mainBanner`, `gpcBanner`, `preferenceModal`). Use the
280
+ wildcard key `'*': null` to delete **every key of the base map that your override doesn't name**:
281
+
282
+ ```ts
283
+ profileOverride: {
284
+ mainBanner: {
285
+ buttons: {
286
+ '*': null, // drop all other built-in buttons
287
+ 'accept-all': { text: 'Allow all' }, // kept — only its text changes
288
+ 'my-button': { text: 'Custom', style: 'secondary', action: 'custom', cookies: '!' },
289
+ },
290
+ },
291
+ preferenceModal: {
292
+ buttons: { '*': null, 'my-save': { text: 'Save', style: 'primary', action: 'submit' } },
293
+ categories: { '*': null, necessary: {}, analytics: {} }, // keep only these two categories
294
+ },
295
+ },
296
+ ```
297
+
298
+ - Keys you **name** are kept and merged onto their base value (so `'accept-all': { text }` keeps
299
+ its `style`/`action`/`cookies`); keys you **don't name** are deleted; new keys are added.
300
+ - It works per map, so you choose which surfaces to replace — leave `gpcBanner` alone and it keeps
301
+ its built-in buttons.
302
+ - `'*': null` alone empties the map. It works on any keyed map (`cookies` too), and under
303
+ `complianceGroupsOverride` and `widget.setProfile()`.
304
+ - `'*'` is reserved in these maps and can't be used as a button/category id.
305
+ - `widget.setProfile()` patches are replayed in order on the resolved profile (rather than being
306
+ folded together first), so `null` and `'*': null` deletes in a later `setProfile()` call are
307
+ honoured even after an earlier override.
308
+
309
+ > **Compliance:** refusing must be as easy as accepting (GDPR Art. 7(3); CNIL/EDPB cookie-banner
310
+ > guidance). If, after overrides, a `mainBanner`/`gpcBanner` has an "accept all" button
311
+ > (`cookies: '*'`) but no reject (`cookies: '!'`) or `manage` button, the widget logs a
312
+ > `console.warn`. Warnings are hidden by default — enable them with `verbose: true` or
313
+ > `core: { console: ['warning'] }`. Keep a reject or manage path when
314
+ > you replace the built-in buttons.
315
+
245
316
  ---
246
317
 
247
318
  ## Compliance Groups
@@ -309,30 +380,67 @@ Set `api.trustDomain: true` to bypass this check (useful for localhost / dev env
309
380
 
310
381
  GPC mode is set per-profile on the server (`gpcMode: 'honor' | 'strict' | 'ignore'`). Widget config profileOverrides overrides the profile value when explicitly set.
311
382
 
383
+ `gpcMode` only applies once the widget itself has loaded and initialized. If a tag-loading script
384
+ sits earlier in `<head>` (GTM/gtag.js, or another script), it can still fire before that. Use
385
+ `buildSyncGpcSnippet()` to close that gap — it returns a tiny, dependency-free `<script>` string to
386
+ place first in `<head>`, ahead of everything else, that checks `navigator.globalPrivacyControl`
387
+ synchronously and pre-freezes Google Consent Mode v2 to denied:
388
+
389
+ ```ts
390
+ import { buildSyncGpcSnippet } from '@consenti/ui'
391
+
392
+ buildSyncGpcSnippet() // default dataLayer name
393
+ buildSyncGpcSnippet({ dataLayerName: 'myDataLayer' }) // custom dataLayer name
394
+ ```
395
+
396
+ Safe to run before Consenti's own `gtm` config pushes its defaults on init — same values, same
397
+ stub-queue, so the second push is a no-op rather than a conflict. Full walkthrough with the
398
+ `<head>` ordering: [Advanced Configuration → GPC](https://consenti.dev/docs/ui/advanced-configuration).
399
+
312
400
  ---
313
401
 
314
402
  ## Age Gate
315
403
 
316
- Set `compliance.ageGate.enabled: true` to require age confirmation before the visitor sees any
317
- other consent UI (banner, GPC, CCPA opt-out — all of it waits behind the age gate on first visit):
404
+ Age gate is a **per-profile, per-locale** setting — not a widget-config option. GDPR Article 8's
405
+ consent age varies 13–16 by EU member state and DPDPA has its own child-data age rules, so one
406
+ global age doesn't fit a multi-region deployment. Enable it on a profile in the dashboard's Profile
407
+ Editor Step 1 (minimum age, optional parental-consent requirement), and author the modal's
408
+ heading/body/button text per locale in the Main Banner content step — the widget reads it off the
409
+ resolved profile (`profile.ageGate`/`profile.ageGateModal`) automatically, no widget config needed.
410
+ For a standalone/local profile (`registerProfile()`), set `ageGate`/`ageGateModal` directly on the
411
+ `EmbeddedProfile`/`EmbeddedTranslations` you register.
318
412
 
319
- ```ts
320
- new ConsentiSetup({
321
- compliance: {
322
- ageGate: { enabled: true, minimumAge: 16, requireParentalConsent: true },
323
- },
324
- })
325
- ```
413
+ When enabled, it blocks every other consent UI (banner, GPC, CCPA opt-out — all of it waits behind
414
+ the age gate on first visit):
326
415
 
327
416
  - **Confirmed** (visitor is old enough) → the normal banner/GPC/CCPA flow proceeds exactly as if
328
417
  the age gate didn't exist, and every consent submission from then on carries `ageVerified: true`.
329
418
  - **Declined, `requireParentalConsent: false`** → a deny-all consent is submitted immediately
330
419
  (mandatory/strictly-necessary cookies still granted), no banner shown, `ageVerified: false`.
331
420
  - **Declined, `requireParentalConsent: true`** → same deny-all submission, plus a
332
- `parentalConsentToken` is generated and a `consenti:parentalConsentRequired` event fires
333
- carrying it. There's no email-verification pipeline built into this package — the event is the
334
- hook for wiring your own out-of-band parental-consent process (once verified externally, call
335
- `PUT /consent/:visitorId` yourself to update the record).
421
+ `parentalConsentToken` is requested from the backend (`POST /consent/:visitorId/parental-consent-request`,
422
+ signed with the backend's `compliance.dataSigningHash` when configured — falls back to a
423
+ client-generated, unsigned token if `api.enabled` is false) and a
424
+ `consenti:parentalConsentRequired` event fires carrying it. There's no email-sending pipeline
425
+ built into this package — the event (or your backend's `eventBus.on('consent.parentalConsentRequired', ...)`
426
+ listener, see the `@consenti/api` README's "Parental consent" section) is the hook for wiring
427
+ your own out-of-band verification process.
428
+
429
+ When the parent later completes that process (often on a fresh page with no live widget instance —
430
+ their own device, not the child's), resolve the token with the standalone `resolveParentalConsent`
431
+ export instead of a `ConsentiSetup` method:
432
+
433
+ ```ts
434
+ import { resolveParentalConsent } from '@consenti/ui'
435
+
436
+ const { visitorId, profileId } = await resolveParentalConsent(token, { baseUrl: 'https://consent.example.com' })
437
+ // dispatches consenti:parentalConsentResolved on window — a page with a live widget instance
438
+ // can listen for it and react (e.g. re-show the banner for the real consent choice); what
439
+ // "resolved" should actually do to consent state is host-defined, same as the request side.
440
+ ```
441
+
442
+ This is plumbing, not verifiable parental consent by itself — see the `@consenti/api` README for
443
+ the full caveats (token replay isn't prevented, staying stateless is a deliberate tradeoff).
336
444
 
337
445
  The prompt only asks once per visitor per profile — it doesn't re-appear once any consent record
338
446
  exists (same "already decided" check the banner itself uses).
@@ -359,9 +467,50 @@ may own `window.__tcfapi` per page — if it's already set (a real CMP, or anoth
359
467
  instance on a multi-profile page), this stub does not overwrite it.
360
468
 
361
469
  This is a simplified implementation: `tcString` is a base64url-encoded JSON payload (the same
362
- format the backend's `tcfString` uses), not the full IAB binary bitfield encoding, and vendor-list
363
- metadata (`gvlVersion`, `publisherCC`) is a placeholder since the widget doesn't fetch the GVL
364
- client-side. For full IAB TCF v2.2 compliance, use the `iabtcf-core` npm package instead.
470
+ default format the backend's `tcfString` uses), not the full IAB binary bitfield encoding, and
471
+ vendor-list metadata (`gvlVersion`, `publisherCC`) is operator-supplied via `TcfWidgetConfig`
472
+ rather than fetched client-side (the GVL is multi-megabyte; IAB policy expects CMPs to cache their
473
+ own copy server-side rather than have every visitor's browser re-fetch it). `gdprApplies` is
474
+ derived automatically from the resolved profile's compliance group.
475
+
476
+ For spec-correct binary TC-string encoding, run with the `@consenti/api` backend, set
477
+ `compliance.tcf.publisherCC`, and install the optional `@iabtechlabtcf/core` peer dependency
478
+ (the actively-maintained IAB Tech Lab package — not `iabtcf-core`, which doesn't exist on npm).
479
+ See the [TCF & GPP Registration Guide](https://consenti.dev/docs/compliance/tcf-and-gpp-registration) for details.
480
+
481
+ ---
482
+
483
+ ## GPP — IAB Global Privacy Platform (US National)
484
+
485
+ Set `compliance.gpp.enabled: true` to install `window.__gpp`, the standard IAB entry point
486
+ scripts call for US state-privacy-law signals (`ping`, `addEventListener`, `removeEventListener`,
487
+ `getSection`, `hasSection`) — `usnat` (US National) section only:
488
+
489
+ ```ts
490
+ new ConsentiSetup({
491
+ compliance: {
492
+ gpp: {
493
+ enabled: true,
494
+ cmpId: 280, // must match the backend's GppConfig
495
+ cmpVersion: 1,
496
+ mspaCoveredTransaction: true, // whether this deployment falls under MSPA signatory obligations
497
+ mspaOptOutOptionMode: 1, // 0 = not applicable | 1 = yes | 2 = no
498
+ mspaServiceProviderMode: 0,
499
+ },
500
+ },
501
+ })
502
+ ```
503
+
504
+ Sale/sharing opt-out flags are derived automatically from the resolved profile's cookies tagged
505
+ `cpraCategory: 'sale'` / `'sharing'` and the visitor's actual consent — no separate config needed.
506
+ Like TCF, only one CMP may own `window.__gpp` per page; this stub does not overwrite an existing one,
507
+ and follows the standard stub-queue convention so load order relative to third-party scripts doesn't
508
+ matter.
509
+
510
+ Unlike TCF, GPP's US National section needs no Global Vendor List, so both this stub's `gppString`
511
+ and the backend's `gppString` use the same real, spec-correct encoder when the optional
512
+ `@iabgpp/cmpapi` peer dependency is installed on `@consenti/api` — there's no "simplified fallback"
513
+ format the way TCF has. Without it, `gppString` falls back to a base64url-encoded JSON payload.
365
514
 
366
515
  ---
367
516
 
@@ -433,7 +582,7 @@ const profile = new ConsentiProfile({
433
582
  // key = machine id, rendered as the button's DOM id: `consenti-btn-{id}`, for targeting a specific button
434
583
  // cookies: '*' = grant all | '!' = deny all | ['id1','id2'] = grant specific
435
584
  'accept-all': { text: 'Accept All', style: 'primary', action: 'custom', cookies: '*' },
436
- 'reject-optional': { text: 'Reject Optional', style: 'secondary', action: 'custom', cookies: '!' },
585
+ 'reject-optional': { text: 'Reject Optional', style: 'primary', action: 'custom', cookies: '!' }, // same weight as accept-all — CNIL requires reject not be visually subordinate
437
586
  customize: { text: 'Customize', style: 'secondary', action: 'manage' },
438
587
  'privacy-policy': { text: 'Privacy Policy', style: 'text', action: 'link', url: '/privacy' },
439
588
  },
@@ -652,11 +801,19 @@ widget.deleteConsent() // delete consent record (cookie + backend if e
652
801
  widget.reConsent() // delete consent and re-show banner
653
802
  widget.destroy() // unmount DOM elements and remove all event listeners
654
803
 
804
+ // Logged-in user identity
805
+ widget.getUserId() // string | null — current application user ID
806
+ widget.setUserId('user-42') // reconsents (by default) if it differs from the stored cookie's user
807
+ widget.setUserId(null) // clear identity (e.g. logout)
808
+ widget.setUserId('user-42', false) // update identity without reconsenting
809
+ // Or dispatch from anywhere (e.g. right after your own login flow completes):
810
+ // window.dispatchEvent(new CustomEvent('consenti:listener:identify', { detail: { userId: 'user-42' } }))
811
+
655
812
  // Runtime configuration
656
813
  widget.setDarkMode() // toggle dark mode
657
814
  widget.setDarkMode(true) // force dark on
658
815
  widget.setDarkMode(false) // force light
659
- widget.setTheme({ primaryColor: '#ff0000' }) // hot-swap CSS tokens (merges into current theme)
816
+ widget.setTheme({ colorPrimary: '#ff0000' }) // hot-swap CSS tokens (merges into current theme)
660
817
  widget.setConfig({ darkMode: true }) // deep-merge partial config (no re-init)
661
818
  widget.setProfile({ mainBanner: { heading: 'Updated' } }) // re-render UI with new profile data
662
819
 
@@ -743,6 +900,14 @@ Custom DOM events fire on `window` at every lifecycle step. All are prefixed `co
743
900
  | `consenti:consentSubmitted` | Consent saved (cookie written + API call if configured) |
744
901
  | `consenti:parentalConsentRequired` | Age gate declined with `requireParentalConsent: true` — carries `parentalConsentToken` |
745
902
 
903
+ The events above are **outbound** (widget → host). `consenti:listener:*` is the opposite
904
+ direction — **inbound** (host → widget), dispatched by your own code to tell the widget
905
+ something:
906
+
907
+ | Event | Dispatch when |
908
+ |--------------------------------|----------------------------------------------------------|
909
+ | `consenti:listener:identify` | Your own login/logout flow completes — `detail: { userId: string \| null, reConsent?: boolean }`, equivalent to calling `widget.setUserId(userId, reConsent)` |
910
+
746
911
  **Recommended:** use `widget.on()` / `widget.off()` for typed subscriptions (the `consenti:` prefix is optional):
747
912
 
748
913
  ```ts
@@ -843,20 +1008,64 @@ new ConsentScript({
843
1008
  })
844
1009
  ```
845
1010
 
846
- ### CookieTrigger — open banner or modal from any element
1011
+ ### ConsentAction — run a callback on a parameter's grant status
1012
+
1013
+ For integrations that expose their own `sdk.optIn()`/`sdk.optOut()` API (Segment, Mixpanel,
1014
+ Amplitude, Sentry, etc.) instead of a `<script>` tag — use `ConsentScript` for that case instead.
1015
+
1016
+ ```ts
1017
+ import { ConsentAction } from '@consenti/ui'
1018
+
1019
+ new ConsentAction({
1020
+ id: 'analytics_storage',
1021
+ widget,
1022
+ onGrant: () => analyticsSdk.optIn(),
1023
+ onDeny: () => analyticsSdk.optOut(),
1024
+ // bind: true (default) — re-fires on every future consent change
1025
+ })
1026
+ ```
1027
+
1028
+ ### CategoryScript / CategoryAction — gate on a whole category
1029
+
1030
+ Same as `ConsentScript`/`ConsentAction`, but keyed on a preference-modal category instead of a
1031
+ single parameter — "granted" only when *every* parameter in the category is `'granted'`.
1032
+
1033
+ ```ts
1034
+ import { CategoryScript, CategoryAction } from '@consenti/ui'
1035
+
1036
+ new CategoryScript({ categoryId: 'marketing', widget, src: 'https://example.com/ad-pixel.js' })
1037
+
1038
+ new CategoryAction({
1039
+ id: 'marketing',
1040
+ widget,
1041
+ onGrant: () => adSdk.enableAll(),
1042
+ onDeny: () => adSdk.disableAll(),
1043
+ })
1044
+ ```
1045
+
1046
+ ### scanConsentScripts — declarative `data-*` script gating
1047
+
1048
+ Zero-JS integration path: mark `<script>` tags with `data-consenti-consent-script` /
1049
+ `data-consenti-category-script` and Consenti scans and wires them up automatically at the end of
1050
+ every `init()` cycle (call `scanConsentScripts(widget)` again manually after adding tags at runtime).
1051
+
1052
+ ```html
1053
+ <script type="text/plain" data-consenti-consent-script="analytics_storage" src="https://www.googletagmanager.com/gtag/js?id=G-XXXXX"></script>
1054
+ <script type="text/plain" data-consenti-category-script="marketing" src="https://example.com/pixel.js"></script>
1055
+ <script type="text/plain" data-consenti-consent-script="ad_storage" data-consenti-bind="false">/* inline snippet, evaluated once */</script>
1056
+ ```
1057
+
1058
+ ### BannerTrigger — open banner or modal from any element
847
1059
 
848
1060
  ```ts
849
- import { CookieTrigger } from '@consenti/ui'
1061
+ import { BannerTrigger } from '@consenti/ui'
850
1062
 
851
1063
  // Attach to existing element
852
- new CookieTrigger({ selector: '#cookie-settings', action: 'modal' })
1064
+ new BannerTrigger({ widget, el: '#cookie-settings', action: 'modal' })
853
1065
 
854
1066
  // Auto-create a button
855
- new CookieTrigger({
856
- action: 'modal',
857
- label: 'Manage Cookies',
858
- appendTo: document.querySelector('#footer'),
859
- })
1067
+ const trigger = new BannerTrigger({ widget, action: 'modal', label: 'Manage Cookies' })
1068
+ document.querySelector('#footer')?.appendChild(trigger.getElement())
860
1069
  ```
861
1070
 
862
1071
  ---
@@ -870,41 +1079,50 @@ Override any token in your own stylesheet:
870
1079
  ```css
871
1080
  :root {
872
1081
  /* Colors */
873
- --consenti-primary-text: #1a3460;
874
- --consenti-secondary-text: #555555;
875
- --consenti-primary-bg: #ffffff;
876
- --consenti-secondary-bg: #f5f5f5;
877
- --consenti-overlay-bg: rgba(0, 0, 0, 0.5);
878
-
879
- /* Buttons */
880
- --consenti-btn-primary-bg: #1565c0;
881
- --consenti-btn-primary-text: #ffffff;
882
- --consenti-btn-secondary-bg: #f5f5f5;
883
- --consenti-btn-secondary-text: #1a3460;
884
- --consenti-btn-radius: 6px;
885
- --consenti-btn-padding: 0.5rem 1.25rem;
886
- --consenti-btn-font-weight: 600;
887
-
888
- /* Banner */
889
- --consenti-banner-shadow: 0 -4px 24px rgba(0, 0, 0, 0.12);
890
- --consenti-banner-radius: 0;
891
- --consenti-banner-padding: 1.5rem;
892
- --consenti-banner-max-width: 960px;
893
-
894
- /* Modal */
895
- --consenti-modal-radius: 12px;
896
- --consenti-modal-shadow: 0 8px 32px rgba(0, 0, 0, 0.24);
897
- --consenti-modal-width: 560px;
898
- --consenti-modal-max-height: 80vh;
899
-
900
- /* Toggle */
901
- --consenti-toggle-on: #1565c0;
902
- --consenti-toggle-off: #ccc;
1082
+ --consenti-color-bg: #ffffff;
1083
+ --consenti-color-text: #1a2e4a;
1084
+ --consenti-color-text-muted: #949dab;
1085
+ --consenti-color-primary: #04111f;
1086
+ --consenti-color-primary-text: #ffffff;
1087
+ --consenti-color-secondary: #f0f4f8;
1088
+ --consenti-color-secondary-text: #1a2e4a;
1089
+ --consenti-color-border: #dbe4ee;
1090
+ --consenti-color-secondary-border: #1a2e4a;
1091
+ --consenti-color-overlay: #04111f;
1092
+ --consenti-color-accent: #d32f2f;
1093
+ --consenti-color-accent-text: #ffffff;
903
1094
 
904
1095
  /* Typography */
905
- --consenti-font-size-base: 14px;
906
- --consenti-font-size-mult: 1;
907
1096
  --consenti-font-family: system-ui, -apple-system, sans-serif;
1097
+ --consenti-font-family-mono: ui-monospace, monospace;
1098
+ --consenti-font-size-base: 14px;
1099
+ --consenti-font-size-heading: 16px;
1100
+ --consenti-font-weight-heading: 600;
1101
+ --consenti-line-height: 1.5;
1102
+
1103
+ /* Spacing */
1104
+ --consenti-spacing-xs: 5px;
1105
+ --consenti-spacing-sm: 8px;
1106
+ --consenti-spacing-md: 16px;
1107
+ --consenti-spacing-lg: 24px;
1108
+
1109
+ /* Shape */
1110
+ --consenti-border-radius: 8px;
1111
+ --consenti-border-radius-btn: 0;
1112
+ --consenti-shadow: 0 4px 24px rgba(21, 101, 192, 0.14);
1113
+
1114
+ /* Toggle (preference modal) */
1115
+ --consenti-toggle-bg-on: #43a047;
1116
+ --consenti-toggle-bg-partial: #97c098;
1117
+ --consenti-toggle-bg-off: #9ca3af;
1118
+ --consenti-toggle-knob: #ffffff;
1119
+ --consenti-toggle-width: 52px;
1120
+ --consenti-toggle-height: 28px;
1121
+
1122
+ /* Stacking */
1123
+ --consenti-z-banner: 9999;
1124
+ --consenti-z-overlay: 9998;
1125
+ --consenti-z-modal: 10000;
908
1126
  }
909
1127
  ```
910
1128
 
@@ -915,10 +1133,10 @@ new ConsentiSetup({
915
1133
  compliance: { type: 'opt-in' },
916
1134
  core: {
917
1135
  theme: {
918
- primaryColor: '#7c3aed', // purple brand
919
- primaryTextColor: '#ffffff',
1136
+ colorPrimary: '#7c3aed', // purple brand
1137
+ colorPrimaryText: '#ffffff',
920
1138
  borderRadius: '12px',
921
- buttonBorderRadius: '999px', // pill buttons
1139
+ borderRadiusBtn: '999px', // pill buttons
922
1140
  fontFamily: 'Inter, sans-serif',
923
1141
  },
924
1142
  },
@@ -983,7 +1201,6 @@ new ConsentiSetup({
983
1201
 
984
1202
  import { useEffect } from 'react'
985
1203
  import { ConsentiSetup } from '@consenti/ui'
986
- import '@consenti/ui/dist/index.css'
987
1204
 
988
1205
  export function ConsentSetup() {
989
1206
  useEffect(() => {
@@ -1036,7 +1253,6 @@ export default function RootLayout({ children }: { children: React.ReactNode })
1036
1253
 
1037
1254
  import { useEffect, useRef } from 'react'
1038
1255
  import type { ConsentiSetup as WidgetType } from '@consenti/ui'
1039
- import '@consenti/ui/dist/index.css'
1040
1256
 
1041
1257
  export function ConsentSetup() {
1042
1258
  const widgetRef = useRef<WidgetType | null>(null)
@@ -1067,7 +1283,6 @@ let widget: unknown = null
1067
1283
 
1068
1284
  onMounted(async () => {
1069
1285
  const { ConsentiSetup } = await import('@consenti/ui')
1070
- await import('@consenti/ui/dist/index.css')
1071
1286
  widget = new ConsentiSetup({ compliance: { type: 'opt-in' } })
1072
1287
  })
1073
1288
 
@@ -1106,15 +1321,18 @@ export class ConsentService implements OnDestroy {
1106
1321
  }
1107
1322
  ```
1108
1323
 
1109
- Or use the built-in Angular service:
1324
+ Or use the built-in Angular integration (`setConsentiWidget()` once at app root, then
1325
+ `injectConsent()` in any component/service):
1110
1326
 
1111
1327
  ```ts
1112
- import { ConsentiService } from '@consenti/ui/angular'
1328
+ import { injectConsent } from '@consenti/ui/angular'
1113
1329
 
1114
1330
  @Component({ /* ... */ })
1115
1331
  export class MyComponent {
1116
- consent = inject(ConsentiService)
1332
+ consent = injectConsent()
1117
1333
  // consent.hasConsent() | consent.showModal() | consent.getConsent()
1334
+ // consent.onConsentChange(cb) / onBannerChange(cb) / onModalChange(cb) — call in
1335
+ // ngOnInit, unsubscribe with the returned function in ngOnDestroy
1118
1336
  }
1119
1337
  ```
1120
1338
 
@@ -1122,7 +1340,6 @@ export class MyComponent {
1122
1340
 
1123
1341
  ```js
1124
1342
  import { ConsentiSetup } from '@consenti/ui'
1125
- import '@consenti/ui/dist/index.css'
1126
1343
 
1127
1344
  const widget = new ConsentiSetup({ compliance: { type: 'opt-in' }, core: { locale: 'en' } })
1128
1345
 
@@ -1227,10 +1444,8 @@ new ConsentiSetup({
1227
1444
  // Arabic site — RTL derived automatically from locale
1228
1445
  new ConsentiSetup({ core: { locale: 'ar' } })
1229
1446
 
1230
- // COPPA — block under-13s, require parental consent
1231
- new ConsentiSetup({
1232
- compliance: { ageGate: { enabled: true, minimumAge: 13, requireParentalConsent: true } },
1233
- })
1447
+ // COPPA — block under-13s, require parental consent: set on the profile in the dashboard
1448
+ // (Profile Editor Step 1 → Enable age gate → minimum age 13, require parental consent), not here.
1234
1449
 
1235
1450
  // TCF — install the __tcfapi stub for ad-tech scripts (cmpId must match the backend's tcf config)
1236
1451
  new ConsentiSetup({
@@ -1250,11 +1465,11 @@ import type {
1250
1465
  CoreConfig, // core section
1251
1466
  ApiConfig, // api section
1252
1467
  ComplianceWidgetConfig, // compliance section
1253
- AgeGateWidgetConfig, // compliance.ageGate section
1254
1468
  WidgetCountryResolverFn, // custom geo resolver function type
1255
1469
  UtilsConfig, // utils section
1256
1470
  GtmConfig, // utils.gtm section
1257
1471
  ThemeConfig, // core.theme section
1472
+ IdentifyEventDetail, // consenti:listener:identify event detail
1258
1473
  ConsentValue, // 'granted' | 'denied' | 'objected'
1259
1474
  ConsentiProfile, // local profile class
1260
1475
  NonEmptyArray, // [T, ...T[]] — used by usePrebuiltProfiles
@@ -1274,6 +1489,17 @@ The following `CoreConfig` fields were **removed** in v0.1.x:
1274
1489
 
1275
1490
  ---
1276
1491
 
1492
+ ## Security notes
1493
+
1494
+ **`core.cookieSigningKey` is spoofable in standalone mode.** Without a `@consenti/api` backend,
1495
+ this key has nowhere to live but the shipped browser bundle — so anyone can read it out of your JS
1496
+ and re-sign a forged cookie with it. It still catches accidental tampering (e.g. a stale value from
1497
+ an older config), but it is not tamper-*proof* against a motivated attacker. If you need consent
1498
+ records that hold up as evidence, sign them server-side instead with `compliance.dataSigningHash`
1499
+ (`@consenti/api`), where the key never reaches the browser.
1500
+
1501
+ ---
1502
+
1277
1503
  ## Contributing
1278
1504
 
1279
1505
  See [CONTRIBUTING.md](../../CONTRIBUTING.md) in the monorepo root.