@consenti/ui 0.3.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.
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) ────────────────────────────────────────────────
@@ -309,30 +339,67 @@ Set `api.trustDomain: true` to bypass this check (useful for localhost / dev env
309
339
 
310
340
  GPC mode is set per-profile on the server (`gpcMode: 'honor' | 'strict' | 'ignore'`). Widget config profileOverrides overrides the profile value when explicitly set.
311
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:
347
+
348
+ ```ts
349
+ import { buildSyncGpcSnippet } from '@consenti/ui'
350
+
351
+ buildSyncGpcSnippet() // default dataLayer name
352
+ buildSyncGpcSnippet({ dataLayerName: 'myDataLayer' }) // custom dataLayer name
353
+ ```
354
+
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).
358
+
312
359
  ---
313
360
 
314
361
  ## Age Gate
315
362
 
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):
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.
318
371
 
319
- ```ts
320
- new ConsentiSetup({
321
- compliance: {
322
- ageGate: { enabled: true, minimumAge: 16, requireParentalConsent: true },
323
- },
324
- })
325
- ```
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):
326
374
 
327
375
  - **Confirmed** (visitor is old enough) → the normal banner/GPC/CCPA flow proceeds exactly as if
328
376
  the age gate didn't exist, and every consent submission from then on carries `ageVerified: true`.
329
377
  - **Declined, `requireParentalConsent: false`** → a deny-all consent is submitted immediately
330
378
  (mandatory/strictly-necessary cookies still granted), no banner shown, `ageVerified: false`.
331
379
  - **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).
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.
399
+ ```
400
+
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).
336
403
 
337
404
  The prompt only asks once per visitor per profile — it doesn't re-appear once any consent record
338
405
  exists (same "already decided" check the banner itself uses).
@@ -359,9 +426,50 @@ may own `window.__tcfapi` per page — if it's already set (a real CMP, or anoth
359
426
  instance on a multi-profile page), this stub does not overwrite it.
360
427
 
361
428
  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.
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.
365
473
 
366
474
  ---
367
475
 
@@ -433,7 +541,7 @@ const profile = new ConsentiProfile({
433
541
  // key = machine id, rendered as the button's DOM id: `consenti-btn-{id}`, for targeting a specific button
434
542
  // cookies: '*' = grant all | '!' = deny all | ['id1','id2'] = grant specific
435
543
  'accept-all': { text: 'Accept All', style: 'primary', action: 'custom', cookies: '*' },
436
- 'reject-optional': { text: 'Reject Optional', style: 'secondary', 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
437
545
  customize: { text: 'Customize', style: 'secondary', action: 'manage' },
438
546
  'privacy-policy': { text: 'Privacy Policy', style: 'text', action: 'link', url: '/privacy' },
439
547
  },
@@ -652,11 +760,19 @@ widget.deleteConsent() // delete consent record (cookie + backend if e
652
760
  widget.reConsent() // delete consent and re-show banner
653
761
  widget.destroy() // unmount DOM elements and remove all event listeners
654
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
+
655
771
  // Runtime configuration
656
772
  widget.setDarkMode() // toggle dark mode
657
773
  widget.setDarkMode(true) // force dark on
658
774
  widget.setDarkMode(false) // force light
659
- 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)
660
776
  widget.setConfig({ darkMode: true }) // deep-merge partial config (no re-init)
661
777
  widget.setProfile({ mainBanner: { heading: 'Updated' } }) // re-render UI with new profile data
662
778
 
@@ -743,6 +859,14 @@ Custom DOM events fire on `window` at every lifecycle step. All are prefixed `co
743
859
  | `consenti:consentSubmitted` | Consent saved (cookie written + API call if configured) |
744
860
  | `consenti:parentalConsentRequired` | Age gate declined with `requireParentalConsent: true` — carries `parentalConsentToken` |
745
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)` |
869
+
746
870
  **Recommended:** use `widget.on()` / `widget.off()` for typed subscriptions (the `consenti:` prefix is optional):
747
871
 
748
872
  ```ts
@@ -843,20 +967,64 @@ new ConsentScript({
843
967
  })
844
968
  ```
845
969
 
846
- ### 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'`.
991
+
992
+ ```ts
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
847
1018
 
848
1019
  ```ts
849
- import { CookieTrigger } from '@consenti/ui'
1020
+ import { BannerTrigger } from '@consenti/ui'
850
1021
 
851
1022
  // Attach to existing element
852
- new CookieTrigger({ selector: '#cookie-settings', action: 'modal' })
1023
+ new BannerTrigger({ widget, el: '#cookie-settings', action: 'modal' })
853
1024
 
854
1025
  // Auto-create a button
855
- new CookieTrigger({
856
- action: 'modal',
857
- label: 'Manage Cookies',
858
- appendTo: document.querySelector('#footer'),
859
- })
1026
+ const trigger = new BannerTrigger({ widget, action: 'modal', label: 'Manage Cookies' })
1027
+ document.querySelector('#footer')?.appendChild(trigger.getElement())
860
1028
  ```
861
1029
 
862
1030
  ---
@@ -870,41 +1038,50 @@ Override any token in your own stylesheet:
870
1038
  ```css
871
1039
  :root {
872
1040
  /* 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;
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;
903
1053
 
904
1054
  /* Typography */
905
- --consenti-font-size-base: 14px;
906
- --consenti-font-size-mult: 1;
907
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;
908
1085
  }
909
1086
  ```
910
1087
 
@@ -915,10 +1092,10 @@ new ConsentiSetup({
915
1092
  compliance: { type: 'opt-in' },
916
1093
  core: {
917
1094
  theme: {
918
- primaryColor: '#7c3aed', // purple brand
919
- primaryTextColor: '#ffffff',
1095
+ colorPrimary: '#7c3aed', // purple brand
1096
+ colorPrimaryText: '#ffffff',
920
1097
  borderRadius: '12px',
921
- buttonBorderRadius: '999px', // pill buttons
1098
+ borderRadiusBtn: '999px', // pill buttons
922
1099
  fontFamily: 'Inter, sans-serif',
923
1100
  },
924
1101
  },
@@ -983,7 +1160,6 @@ new ConsentiSetup({
983
1160
 
984
1161
  import { useEffect } from 'react'
985
1162
  import { ConsentiSetup } from '@consenti/ui'
986
- import '@consenti/ui/dist/index.css'
987
1163
 
988
1164
  export function ConsentSetup() {
989
1165
  useEffect(() => {
@@ -1036,7 +1212,6 @@ export default function RootLayout({ children }: { children: React.ReactNode })
1036
1212
 
1037
1213
  import { useEffect, useRef } from 'react'
1038
1214
  import type { ConsentiSetup as WidgetType } from '@consenti/ui'
1039
- import '@consenti/ui/dist/index.css'
1040
1215
 
1041
1216
  export function ConsentSetup() {
1042
1217
  const widgetRef = useRef<WidgetType | null>(null)
@@ -1067,7 +1242,6 @@ let widget: unknown = null
1067
1242
 
1068
1243
  onMounted(async () => {
1069
1244
  const { ConsentiSetup } = await import('@consenti/ui')
1070
- await import('@consenti/ui/dist/index.css')
1071
1245
  widget = new ConsentiSetup({ compliance: { type: 'opt-in' } })
1072
1246
  })
1073
1247
 
@@ -1106,15 +1280,18 @@ export class ConsentService implements OnDestroy {
1106
1280
  }
1107
1281
  ```
1108
1282
 
1109
- 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):
1110
1285
 
1111
1286
  ```ts
1112
- import { ConsentiService } from '@consenti/ui/angular'
1287
+ import { injectConsent } from '@consenti/ui/angular'
1113
1288
 
1114
1289
  @Component({ /* ... */ })
1115
1290
  export class MyComponent {
1116
- consent = inject(ConsentiService)
1291
+ consent = injectConsent()
1117
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
1118
1295
  }
1119
1296
  ```
1120
1297
 
@@ -1122,7 +1299,6 @@ export class MyComponent {
1122
1299
 
1123
1300
  ```js
1124
1301
  import { ConsentiSetup } from '@consenti/ui'
1125
- import '@consenti/ui/dist/index.css'
1126
1302
 
1127
1303
  const widget = new ConsentiSetup({ compliance: { type: 'opt-in' }, core: { locale: 'en' } })
1128
1304
 
@@ -1227,10 +1403,8 @@ new ConsentiSetup({
1227
1403
  // Arabic site — RTL derived automatically from locale
1228
1404
  new ConsentiSetup({ core: { locale: 'ar' } })
1229
1405
 
1230
- // COPPA — block under-13s, require parental consent
1231
- new ConsentiSetup({
1232
- compliance: { ageGate: { enabled: true, minimumAge: 13, requireParentalConsent: true } },
1233
- })
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.
1234
1408
 
1235
1409
  // TCF — install the __tcfapi stub for ad-tech scripts (cmpId must match the backend's tcf config)
1236
1410
  new ConsentiSetup({
@@ -1250,11 +1424,11 @@ import type {
1250
1424
  CoreConfig, // core section
1251
1425
  ApiConfig, // api section
1252
1426
  ComplianceWidgetConfig, // compliance section
1253
- AgeGateWidgetConfig, // compliance.ageGate section
1254
1427
  WidgetCountryResolverFn, // custom geo resolver function type
1255
1428
  UtilsConfig, // utils section
1256
1429
  GtmConfig, // utils.gtm section
1257
1430
  ThemeConfig, // core.theme section
1431
+ IdentifyEventDetail, // consenti:listener:identify event detail
1258
1432
  ConsentValue, // 'granted' | 'denied' | 'objected'
1259
1433
  ConsentiProfile, // local profile class
1260
1434
  NonEmptyArray, // [T, ...T[]] — used by usePrebuiltProfiles
@@ -1274,6 +1448,17 @@ The following `CoreConfig` fields were **removed** in v0.1.x:
1274
1448
 
1275
1449
  ---
1276
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.
1459
+
1460
+ ---
1461
+
1277
1462
  ## Contributing
1278
1463
 
1279
1464
  See [CONTRIBUTING.md](../../CONTRIBUTING.md) in the monorepo root.