@consenti/ui 0.1.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 ADDED
@@ -0,0 +1,888 @@
1
+ # @consenti/ui
2
+
3
+ Zero-dependency, GDPR-compliant cookie consent widget for any web framework.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@consenti/ui.svg)](https://www.npmjs.com/package/@consenti/ui)
6
+ [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](../../LICENSE)
7
+ [![Browser](https://img.shields.io/badge/browser-ES2020%2B-lightgrey.svg)](https://caniuse.com)
8
+
9
+ ---
10
+
11
+ ## Why this package?
12
+
13
+ - **Zero runtime dependencies** — browser built-ins only (`fetch`, `crypto.subtle`, `BroadcastChannel`, `CustomEvent`, `document.cookie`, `localStorage`)
14
+ - **Fully standalone or API-connected** — works offline with a local profile, or connects to `@consenti/api` for dashboard-managed profiles
15
+ - **Multi-regulation** — GDPR, UK-GDPR, CCPA, CPRA, LGPD, DPDPA, PIPEDA, POPIA, PDPA-TH, APPI, KVKK
16
+ - **GPC aware** — Global Privacy Control signal detection with `true` / `'strict'` modes
17
+ - **GTM / Google Consent Mode v2** — built-in `dataLayer` integration
18
+ - **SSR-safe** — all browser API access is guarded; `new ConsentiSetup()` during SSR is a silent no-op
19
+ - **Fully typed** — ships TypeScript definitions; no `@types` needed
20
+
21
+ ---
22
+
23
+ ## Browser Support
24
+
25
+ ES2020+ · Chrome 80+ · Firefox 74+ · Safari 13.1+
26
+
27
+ ---
28
+
29
+ ## Installation
30
+
31
+ ### npm (recommended)
32
+
33
+ ```bash
34
+ npm install @consenti/ui
35
+ ```
36
+
37
+ Then import CSS and the class:
38
+
39
+ ```ts
40
+ import { ConsentiSetup } from '@consenti/ui'
41
+ import '@consenti/ui/dist/index.css'
42
+ ```
43
+
44
+ ### CDN / UMD (no build step)
45
+
46
+ ```html
47
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@consenti/ui/dist/index.css" />
48
+ <script src="https://cdn.jsdelivr.net/npm/@consenti/ui/dist/index.umd.js"></script>
49
+ <script>
50
+ const { ConsentiSetup } = ConsentiUI
51
+ new ConsentiSetup({ core: { regulation: 'gdpr' } })
52
+ </script>
53
+ ```
54
+
55
+ ### ESM in the browser (no bundler)
56
+
57
+ ```html
58
+ <script type="module">
59
+ import { ConsentiSetup } from 'https://esm.sh/@consenti/ui'
60
+ new ConsentiSetup({ core: { regulation: 'gdpr' } })
61
+ </script>
62
+ ```
63
+
64
+ ### CSS options
65
+
66
+ | Approach | How |
67
+ |-----------------------------------|-----------------------------------------------------------------------|
68
+ | Import from package (recommended) | `import '@consenti/ui/dist/index.css'` |
69
+ | Skip all CSS (bring your own) | Set `core.disableCssTemplate: true` — no styles injected |
70
+ | CSS custom properties only | Import CSS, then override `--consenti-*` variables in your stylesheet |
71
+
72
+ ---
73
+
74
+ ## Quick Start
75
+
76
+ ```ts
77
+ // Simplest possible — built-in GDPR profile, no backend required
78
+ new ConsentiSetup({ core: { regulation: 'gdpr' } })
79
+ ```
80
+
81
+ A consent banner appears on first visit. Everything below is optional.
82
+
83
+ ---
84
+
85
+ ## Package Exports
86
+
87
+ ```
88
+ @consenti/ui ← main entry (ConsentiSetup, ConsentiProfile, etc.)
89
+ @consenti/ui/react ← React hook (useConsent)
90
+ @consenti/ui/vue ← Vue composable (useConsent)
91
+ @consenti/ui/angular ← Angular service (ConsentiService)
92
+ @consenti/ui/testing ← Test utilities
93
+ ```
94
+
95
+ ---
96
+
97
+ ## Full Configuration
98
+
99
+ ```ts
100
+ import { ConsentiSetup } from '@consenti/ui'
101
+ import '@consenti/ui/dist/index.css'
102
+
103
+ const widget = new ConsentiSetup({
104
+ // ── Required ────────────────────────────────────────────────────────────────
105
+ core: {
106
+ profileId: 1, // 0 = built-in default; >0 = local or API profile
107
+ regulation: 'gdpr', // controls opt-in / opt-out behaviour
108
+ locale: 'en', // BCP 47; falls back to language prefix, then 'en'
109
+ autoHonorGPC: true, // false | true | 'strict'
110
+ storage: 'cookie', // 'cookie' | 'localStorage'
111
+ cookieDomains: '.example.com',
112
+ privacyPolicyUrl: '/privacy',
113
+ signCookies: true,
114
+ cookieSigningKey: 'min-32-char-secret', // client-only mode; use API mode in production
115
+ allowReceipt: true,
116
+ disableCssTemplate: false,
117
+ userId: 'server-assigned-uuid', // authenticated users — enables cross-device sync
118
+ theme: {
119
+ bgColor: '#ffffff',
120
+ textColor: '#1a1a1a',
121
+ primaryColor: '#1565c0',
122
+ primaryTextColor: '#ffffff',
123
+ secondaryColor: '#f0f4f8',
124
+ secondaryTextColor: '#1a3460',
125
+ borderColor: '#e2e8f0',
126
+ accentColor: '#d32f2f',
127
+ accentTextColor: '#ffffff',
128
+ fontFamily: 'system-ui, sans-serif',
129
+ fontSizeBase: '14px',
130
+ fontSizeHeading: '18px',
131
+ fontSizeMultiplier: '1',
132
+ borderRadius: '8px',
133
+ buttonBorderRadius: '4px',
134
+ toggleBgOn: '#1565c0',
135
+ toggleBgOff: '#cccccc',
136
+ },
137
+ },
138
+
139
+ // ── Mount point (optional) ────────────────────────────────────────────────
140
+ rootEl: '#my-consent-wrapper', // CSS selector or HTMLElement; default: appends to body
141
+
142
+ // ── Dark mode (optional) ──────────────────────────────────────────────────
143
+ darkMode: false, // or: window.matchMedia('(prefers-color-scheme: dark)').matches
144
+
145
+ // ── Backend API (optional) ────────────────────────────────────────────────
146
+ api: {
147
+ enabled: true,
148
+ baseUrl: 'https://your-site.com', // default: window.location.origin
149
+ authToken: '', // sent as Authorization: Bearer <token>
150
+ },
151
+
152
+ // ── GTM / Google Consent Mode v2 (optional) ───────────────────────────────
153
+ utils: {
154
+ gtm: {
155
+ containerId: 'GTM-XXXXXX',
156
+ dataLayer: 'dataLayer',
157
+ events: [], // [] = all events; list names to filter
158
+ urlPassthrough: true, // cookieless conversion modelling
159
+ adsDataRedaction: false, // redact ad pings when consent denied
160
+ },
161
+ },
162
+
163
+ // ── Frontend plugins (optional) ───────────────────────────────────────────
164
+ plugins: [],
165
+
166
+ // ── Runtime profile overrides (optional) ──────────────────────────────────
167
+ profileOverride: {
168
+ mainBanner: { position: 'top' },
169
+ },
170
+ })
171
+ ```
172
+
173
+ ---
174
+
175
+ ## Regulations
176
+
177
+ | Value | Region | Model | Notes |
178
+ |--------------|---------------------|---------------------|------------------------------------------------------------------------|
179
+ | `'gdpr'` | EU / EEA | Opt-in | Default. Banner on first visit; all non-mandatory cookies denied until granted. |
180
+ | `'uk-gdpr'` | UK | Opt-in | Same model as GDPR; ICO-enforced; age gate at 13. |
181
+ | `'ccpa'` | California (US) | Opt-out | All cookies default to `'granted'`; consent written silently; no banner. |
182
+ | `'cpra'` | California (US) | Opt-out / Opt-in | Supersedes CCPA. Opt-out for sale/sharing; opt-in for sensitive data. GPC triggers Do Not Sell + Do Not Share. |
183
+ | `'lgpd'` | Brazil | Opt-in | 10 lawful bases; ANPD-enforced; parental consent gate under-12. |
184
+ | `'dpdpa'` | India | Opt-in | Fiduciary name + grievance officer in modal; GPC ignored. |
185
+ | `'pipeda'` | Canada | Opt-in | PIPEDA + Quebec Law 25 (GDPR-aligned). |
186
+ | `'popia'` | South Africa | Opt-in | 8 processing conditions; Information Regulator-enforced. |
187
+ | `'pdpa-th'` | Thailand | Opt-in | Cross-border transfer rules; PDPC-enforced. |
188
+ | `'appi'` | Japan | Opt-in / Opt-out | Opt-in for sensitive data and overseas transfers; opt-out for general third-party sharing. |
189
+ | `'kvkk'` | Turkey | Opt-in | Explicit consent for sensitive personal data; KVK Board-enforced. |
190
+
191
+ ---
192
+
193
+ ## GPC — Global Privacy Control
194
+
195
+ ```ts
196
+ new ConsentiSetup({
197
+ core: {
198
+ regulation: 'gdpr',
199
+ autoHonorGPC: true, // pre-deny 'listenGpc' cookies and show GPC banner variant
200
+ // autoHonorGPC: 'strict' // pre-deny silently — no banner shown at all
201
+ },
202
+ })
203
+ ```
204
+
205
+ Cookies with `listenGpc: true` in the profile are automatically denied when the signal is detected.
206
+
207
+ ---
208
+
209
+ ## Profiles
210
+
211
+ ### Built-in default profile
212
+
213
+ When `core.profileId` is `0` or omitted, the widget uses a built-in English GDPR profile covering the five Google Consent Mode v2 purposes. No configuration needed.
214
+
215
+ ### Local profile (no backend)
216
+
217
+ Define a full profile in JavaScript — no server required.
218
+
219
+ ```ts
220
+ import { ConsentiProfile, ConsentiSetup } from '@consenti/ui'
221
+
222
+ const profile = new ConsentiProfile({
223
+ defaultLocale: 'en',
224
+ cookies: [
225
+ { id: 'necessary', mandatory: true, expiry: 365 },
226
+ { id: 'analytics', listenGpc: true, expiry: 365 },
227
+ { id: 'marketing', listenGpc: true, expiry: 365, cpraCategory: 'sale' },
228
+ // cpraCategory: 'sale' | 'sharing' | 'sensitive' — used when regulation: 'cpra'
229
+ ],
230
+ translations: {
231
+ en: {
232
+ mainBanner: {
233
+ position: 'bottom', // 'top' | 'bottom' | 'middle' | 'left-bottom' | 'right-bottom'
234
+ overlayOpacity: 0, // 0–100
235
+ showClose: false,
236
+ showLocaleSwitcher: false, // true = show locale switcher (requires multiple locales)
237
+ heading: 'We value your privacy',
238
+ headingTag: 'h2', // HTML tag for the heading; default 'h2'
239
+ htmlText: 'We use cookies to improve your experience.',
240
+ buttons: [
241
+ { text: 'Accept All', style: 'primary', action: 'custom', cookies: '*' },
242
+ // cookies: '*' = grant all | '!' = deny all | ['id1','id2'] = grant specific
243
+ { text: 'Reject All', style: 'secondary', action: 'custom', cookies: '!' },
244
+ { text: 'Manage Preferences', style: 'secondary', action: 'manage' },
245
+ { text: 'Privacy Policy', style: 'text', action: 'link', url: '/privacy' },
246
+ // action: 'link' + url — renders as a text link below the banner
247
+ ],
248
+ },
249
+ gpcBanner: { // shown instead of mainBanner when GPC detected
250
+ position: 'bottom',
251
+ heading: 'Privacy signal detected',
252
+ headingTag: 'h2',
253
+ showLocaleSwitcher: false,
254
+ htmlText: "Your browser's GPC signal was detected. Ad cookies have been pre-denied.",
255
+ buttons: [
256
+ { text: 'Understood', style: 'primary', action: 'custom', cookies: '!' },
257
+ { text: 'Manage Preferences', style: 'secondary', action: 'manage' },
258
+ ],
259
+ },
260
+ preferenceModal: {
261
+ heading: 'Cookie Preferences',
262
+ headingTag: 'h2', // HTML tag for the modal heading; default 'h2'
263
+ subheading: 'Choose which cookies you allow.',
264
+ htmlText: 'We use different types of cookies. You can enable or disable each category below.',
265
+ position: 'center', // 'left' | 'right' | 'center'
266
+ showClose: true,
267
+ showLocaleSwitcher: false,
268
+ persistent: false, // true = cannot dismiss by clicking outside
269
+ overlayOpacity: 50,
270
+ buttons: [
271
+ { text: 'Accept All', style: 'primary', action: 'custom', cookies: '*' },
272
+ { text: 'Save Preferences', style: 'primary', action: 'submit' },
273
+ { text: 'Reject All', style: 'text', action: 'custom', cookies: '!' },
274
+ ],
275
+ categories: [
276
+ {
277
+ id: 'cat-necessary',
278
+ heading: 'Strictly Necessary',
279
+ headingTag: 'h3', // HTML tag for the category heading; default 'h3'
280
+ htmlText: 'Required for the site to function. <strong>Cannot be disabled.</strong>',
281
+ mandatory: true,
282
+ cookies: ['necessary'],
283
+ },
284
+ {
285
+ id: 'cat-analytics',
286
+ heading: 'Analytics',
287
+ htmlText: 'Helps us understand how visitors use the site.',
288
+ type: 'consent', // 'consent' | 'legitimate_interest'
289
+ // type: 'legitimate_interest' — refusal stored as 'objected' instead of 'denied'
290
+ cookies: ['analytics'],
291
+ },
292
+ {
293
+ id: 'cat-marketing',
294
+ heading: 'Marketing',
295
+ htmlText: 'Used to personalise ads and measure campaign performance.',
296
+ cookies: ['marketing'],
297
+ },
298
+ ],
299
+ },
300
+ },
301
+ fr: {
302
+ // same shape — add translations for each locale
303
+ mainBanner: { /* ... */ },
304
+ preferenceModal: { /* ... */ },
305
+ },
306
+ },
307
+ })
308
+
309
+ new ConsentiSetup({
310
+ core: {
311
+ profileId: profile.getId(), // always use .getId() — never hardcode the ID
312
+ regulation: 'gdpr',
313
+ locale: 'fr',
314
+ },
315
+ })
316
+ ```
317
+
318
+ > **Note:** Local profile IDs are auto-assigned starting from `1000` to avoid collisions with API profile IDs (which start from `1`). Always call `profile.getId()`.
319
+
320
+ ### API profile (with backend)
321
+
322
+ ```ts
323
+ new ConsentiSetup({
324
+ core: { profileId: 3, regulation: 'gdpr', locale: 'de' },
325
+ api: {
326
+ enabled: true,
327
+ baseUrl: 'https://consent.example.com',
328
+ },
329
+ })
330
+ ```
331
+
332
+ The widget fetches the profile from the API and falls back to a local profile (or the built-in default) if the request fails.
333
+
334
+ ---
335
+
336
+ ## Cookie Format
337
+
338
+ Consent is persisted as a single cookie named `consenti_{profileId}`.
339
+
340
+ ```
341
+ t:{unixTimestamp}::{cookieId}:{status}|{cookieId}:{status}|…[::sig:{hmac}]
342
+ ```
343
+
344
+ Example:
345
+ ```
346
+ t:1782133054::analytics:granted|marketing:denied|necessary:granted::sig:abc123
347
+ ```
348
+
349
+ ---
350
+
351
+ ## Widget API Methods
352
+
353
+ ```ts
354
+ const widget = new ConsentiSetup({ core: { regulation: 'gdpr' } })
355
+
356
+ // Consent state
357
+ widget.hasConsent() // boolean — true if valid consent record exists
358
+ widget.getConsent() // Record<string, 'granted'|'denied'|'objected'> | null
359
+ widget.getGTMConsent() // consent in GTM / Google Consent Mode v2 format
360
+ widget.getConsentDate() // Date | false — time of last submission
361
+
362
+ // Visibility
363
+ widget.showBanner(gpc?) // show main banner (or GPC variant)
364
+ widget.hideBanner() // hide banner
365
+ widget.showModal() // open preference modal
366
+ widget.hideModal() // close preference modal
367
+ widget.bannerVisibility() // 'main' | 'gpc' | false
368
+ widget.modalVisibility() // 'preference' | false
369
+
370
+ // Actions
371
+ widget.init() // manually start init (when autoInit: false)
372
+ widget.onReady(callback) // called once widget is fully initialised
373
+ widget.switchLocale(locale) // switch locale and re-render (e.g. 'fr', 'de-AT')
374
+ widget.submitConsent(consent) // programmatically submit consent values
375
+ widget.deleteConsent() // delete consent record (cookie + backend if enabled)
376
+ widget.reConsent() // delete consent and re-show banner
377
+ widget.downloadReceipt() // trigger JSON consent receipt download
378
+ widget.destroy() // unmount DOM elements and remove all event listeners
379
+ ```
380
+
381
+ ### Manual init
382
+
383
+ ```ts
384
+ const widget = new ConsentiSetup({
385
+ core: {},
386
+ rootEl: '#consent-mount',
387
+ autoInit: false,
388
+ })
389
+
390
+ // Later, once mount point is in the DOM:
391
+ await widget.init()
392
+ widget.onReady(() => console.log('Ready:', widget.hasConsent()))
393
+ ```
394
+
395
+ ### Programmatic consent
396
+
397
+ ```ts
398
+ await widget.submitConsent({
399
+ analytics: 'granted',
400
+ marketing: 'denied',
401
+ necessary: 'granted', // mandatory cookies are always 'granted' regardless
402
+ })
403
+ ```
404
+
405
+ ---
406
+
407
+ ## Events
408
+
409
+ Custom DOM events fire on `document` at every lifecycle step. All are prefixed `consenti:`.
410
+
411
+ | Event | Fired when | Detail type |
412
+ |--------------------------------|----------------------------------------------------------|--------------------------------|
413
+ | `consenti:bannerInitialized` | Widget initialises and determines banner visibility | `BannerInitializedDetail` |
414
+ | `consenti:bannerVisibility` | Banner shows or hides | `BannerVisibilityDetail` |
415
+ | `consenti:modalVisibility` | Preference modal shows or hides | `ModalVisibilityDetail` |
416
+ | `consenti:consentBeingSubmitted` | User clicked a consent button (before API call) | `ConsentBeingSubmittedDetail` |
417
+ | `consenti:consentSubmitted` | Consent saved (cookie written + API call if configured) | `ConsentSubmittedDetail` |
418
+
419
+ ```ts
420
+ document.addEventListener('consenti:consentSubmitted', (e: Event) => {
421
+ const detail = (e as CustomEvent<ConsentSubmittedDetail>).detail
422
+ // detail.consentAction — 'accept_all' | 'reject_all' | 'custom' | 'update'
423
+ // detail.consentJson — { analytics: 'granted', marketing: 'denied', ... }
424
+ // detail.visitorId — stable UUID
425
+ // detail.pageUrl — window.location.href at submission time
426
+ // detail.gpcDetected — boolean
427
+ // detail.timestamp — ISO 8601
428
+ // detail.apiResponse — backend response (if api.enabled: true)
429
+ })
430
+ ```
431
+
432
+ ---
433
+
434
+ ## GTM / Google Consent Mode v2
435
+
436
+ ```ts
437
+ new ConsentiSetup({
438
+ core: { regulation: 'gdpr' },
439
+ utils: {
440
+ gtm: {
441
+ containerId: 'GTM-XXXXXX',
442
+ urlPassthrough: true, // cookieless conversion modelling
443
+ adsDataRedaction: true, // redact ad pings when consent denied
444
+ },
445
+ },
446
+ })
447
+ ```
448
+
449
+ Consenti pushes to `window.dataLayer` in the standard Google Consent Mode v2 format:
450
+
451
+ ```js
452
+ // On initialisation (default denied state)
453
+ dataLayer.push({ 'event': 'consent_default', 'analytics_storage': 'denied', ... })
454
+
455
+ // On submission
456
+ dataLayer.push({ 'event': 'consent_update', 'analytics_storage': 'granted', 'ad_storage': 'denied', ... })
457
+ ```
458
+
459
+ `getGTMConsent()` returns consent in GTM format:
460
+
461
+ ```ts
462
+ widget.getGTMConsent()
463
+ // {
464
+ // analytics_storage: 'granted',
465
+ // ad_storage: 'denied',
466
+ // ad_user_data: 'denied',
467
+ // ad_personalization: 'denied',
468
+ // functionality_storage: 'granted',
469
+ // }
470
+ ```
471
+
472
+ ---
473
+
474
+ ## Utilities
475
+
476
+ ### ConsentScript — load scripts on consent
477
+
478
+ ```ts
479
+ import { ConsentScript } from '@consenti/ui'
480
+
481
+ new ConsentScript({
482
+ cookieId: 'analytics',
483
+ src: 'https://www.googletagmanager.com/gtag/js?id=G-XXXXX',
484
+ onLoad: () => console.log('Analytics loaded'),
485
+ onRevoke: () => console.log('Analytics removed'),
486
+ })
487
+ ```
488
+
489
+ ### CookieTrigger — open banner or modal from any element
490
+
491
+ ```ts
492
+ import { CookieTrigger } from '@consenti/ui'
493
+
494
+ // Attach to existing element
495
+ new CookieTrigger({ selector: '#cookie-settings', action: 'modal' })
496
+
497
+ // Auto-create a button
498
+ new CookieTrigger({
499
+ action: 'modal',
500
+ label: 'Manage Cookies',
501
+ appendTo: document.querySelector('#footer'),
502
+ })
503
+ ```
504
+
505
+ ---
506
+
507
+ ## Themes & CSS
508
+
509
+ ### CSS custom properties
510
+
511
+ Override any token in your own stylesheet:
512
+
513
+ ```css
514
+ :root {
515
+ /* Colors */
516
+ --consenti-primary-text: #1a3460;
517
+ --consenti-secondary-text: #555555;
518
+ --consenti-primary-bg: #ffffff;
519
+ --consenti-secondary-bg: #f5f5f5;
520
+ --consenti-overlay-bg: rgba(0, 0, 0, 0.5);
521
+
522
+ /* Buttons */
523
+ --consenti-btn-primary-bg: #1565c0;
524
+ --consenti-btn-primary-text: #ffffff;
525
+ --consenti-btn-secondary-bg: #f5f5f5;
526
+ --consenti-btn-secondary-text: #1a3460;
527
+ --consenti-btn-radius: 6px;
528
+ --consenti-btn-padding: 0.5rem 1.25rem;
529
+ --consenti-btn-font-weight: 600;
530
+
531
+ /* Banner */
532
+ --consenti-banner-shadow: 0 -4px 24px rgba(0, 0, 0, 0.12);
533
+ --consenti-banner-radius: 0;
534
+ --consenti-banner-padding: 1.5rem;
535
+ --consenti-banner-max-width: 960px;
536
+
537
+ /* Modal */
538
+ --consenti-modal-radius: 12px;
539
+ --consenti-modal-shadow: 0 8px 32px rgba(0, 0, 0, 0.24);
540
+ --consenti-modal-width: 560px;
541
+ --consenti-modal-max-height: 80vh;
542
+
543
+ /* Toggle */
544
+ --consenti-toggle-on: #1565c0;
545
+ --consenti-toggle-off: #ccc;
546
+
547
+ /* Typography */
548
+ --consenti-font-size-base: 14px;
549
+ --consenti-font-size-mult: 1;
550
+ --consenti-font-family: system-ui, -apple-system, sans-serif;
551
+ }
552
+ ```
553
+
554
+ ### Via JS theme config
555
+
556
+ ```ts
557
+ new ConsentiSetup({
558
+ core: {
559
+ regulation: 'gdpr',
560
+ theme: {
561
+ primaryColor: '#7c3aed', // purple brand
562
+ primaryTextColor: '#ffffff',
563
+ borderRadius: '12px',
564
+ buttonBorderRadius: '999px', // pill buttons
565
+ fontFamily: 'Inter, sans-serif',
566
+ },
567
+ },
568
+ })
569
+ ```
570
+
571
+ ### Dark mode
572
+
573
+ ```ts
574
+ new ConsentiSetup({
575
+ core: { profileId: 1 },
576
+ darkMode: true, // or: window.matchMedia('(prefers-color-scheme: dark)').matches
577
+ })
578
+ ```
579
+
580
+ ### BEM class reference
581
+
582
+ **Banner:**
583
+
584
+ | Class | Element |
585
+ |------------------------------------|----------------------------------------------|
586
+ | `.consenti-banner` | Banner root element |
587
+ | `.consenti-banner--top` | Position modifier (`top` / `middle` / `left-bottom` / `right-bottom`) |
588
+ | `.consenti-banner--gpc` | GPC banner modifier |
589
+ | `.consenti-banner__heading` | Banner heading |
590
+ | `.consenti-banner__text` | Banner HTML text |
591
+ | `.consenti-banner__buttons` | Button row |
592
+ | `.consenti-banner__close` | Close × button |
593
+
594
+ **Modal:**
595
+
596
+ | Class | Element |
597
+ |------------------------------------|----------------------------------------------|
598
+ | `.consenti-overlay` | Full-screen overlay backdrop |
599
+ | `.consenti-modal` | Modal root |
600
+ | `.consenti-modal__heading` | Modal heading |
601
+ | `.consenti-modal__categories` | Category list |
602
+ | `.consenti-category` | Single category block |
603
+ | `.consenti-category__toggle` | Category enable/disable toggle |
604
+ | `.consenti-category__toggle--mandatory` | Disabled toggle for mandatory categories |
605
+ | `.consenti-modal__buttons` | Button row |
606
+
607
+ **Buttons:**
608
+
609
+ | Class | Description |
610
+ |--------------------------|--------------------------|
611
+ | `.consenti-btn` | Base button |
612
+ | `.consenti-btn--primary` | Primary CTA (filled) |
613
+ | `.consenti-btn--secondary` | Secondary (outlined) |
614
+ | `.consenti-btn--text` | Text/link style |
615
+ | `.consenti-btn--submit` | Save in modal |
616
+ | `.consenti-btn--manage` | Opens preference modal |
617
+
618
+ ---
619
+
620
+ ## Framework Guides
621
+
622
+ ### React
623
+
624
+ ```tsx
625
+ 'use client' // Next.js App Router
626
+
627
+ import { useEffect } from 'react'
628
+ import { ConsentiSetup } from '@consenti/ui'
629
+ import '@consenti/ui/dist/index.css'
630
+
631
+ export function ConsentSetup() {
632
+ useEffect(() => {
633
+ const widget = new ConsentiSetup({
634
+ core: { regulation: 'gdpr', locale: 'en' },
635
+ })
636
+ return () => widget.destroy()
637
+ }, [])
638
+
639
+ return null
640
+ }
641
+ ```
642
+
643
+ #### `useConsent` hook
644
+
645
+ ```tsx
646
+ import { useConsent } from '@consenti/ui/react'
647
+
648
+ export function AnalyticsButton() {
649
+ const { hasConsent, consent, showModal } = useConsent()
650
+
651
+ if (!hasConsent) return <button onClick={showModal}>Enable Analytics</button>
652
+
653
+ return <span>{consent?.analytics === 'granted' ? 'Analytics on' : 'Analytics off'}</span>
654
+ }
655
+ ```
656
+
657
+ ### Next.js App Router
658
+
659
+ ```tsx
660
+ // app/layout.tsx
661
+ import { ConsentSetup } from '@/components/ConsentSetup'
662
+
663
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
664
+ return (
665
+ <html lang="en">
666
+ <body>
667
+ <ConsentSetup />
668
+ {children}
669
+ </body>
670
+ </html>
671
+ )
672
+ }
673
+ ```
674
+
675
+ ```tsx
676
+ // components/ConsentSetup.tsx
677
+ 'use client'
678
+
679
+ import { useEffect, useRef } from 'react'
680
+ import type { ConsentiSetup as WidgetType } from '@consenti/ui'
681
+ import '@consenti/ui/dist/index.css'
682
+
683
+ export function ConsentSetup() {
684
+ const widgetRef = useRef<WidgetType | null>(null)
685
+
686
+ useEffect(() => {
687
+ let widget: WidgetType
688
+ import('@consenti/ui').then(({ ConsentiSetup }) => {
689
+ widget = new ConsentiSetup({
690
+ core: { regulation: 'gdpr', locale: 'en', autoHonorGPC: true },
691
+ api: { enabled: true, baseUrl: process.env.NEXT_PUBLIC_API_URL },
692
+ })
693
+ widgetRef.current = widget
694
+ })
695
+ return () => widgetRef.current?.destroy()
696
+ }, [])
697
+
698
+ return null
699
+ }
700
+ ```
701
+
702
+ ### Vue 3 / Nuxt
703
+
704
+ ```vue
705
+ <script setup lang="ts">
706
+ import { onMounted, onBeforeUnmount } from 'vue'
707
+
708
+ let widget: unknown = null
709
+
710
+ onMounted(async () => {
711
+ const { ConsentiSetup } = await import('@consenti/ui')
712
+ await import('@consenti/ui/dist/index.css')
713
+ widget = new ConsentiSetup({ core: { regulation: 'gdpr' } })
714
+ })
715
+
716
+ onBeforeUnmount(() => (widget as { destroy?: () => void })?.destroy?.())
717
+ </script>
718
+ ```
719
+
720
+ #### Vue composable
721
+
722
+ ```ts
723
+ import { useConsent } from '@consenti/ui/vue'
724
+ const { hasConsent, consent, showModal } = useConsent()
725
+ ```
726
+
727
+ ### Angular
728
+
729
+ ```ts
730
+ // consent.service.ts
731
+ import { Injectable, OnDestroy, inject, PLATFORM_ID } from '@angular/core'
732
+ import { isPlatformBrowser } from '@angular/common'
733
+
734
+ @Injectable({ providedIn: 'root' })
735
+ export class ConsentService implements OnDestroy {
736
+ private platformId = inject(PLATFORM_ID)
737
+ private widget: unknown = null
738
+
739
+ async init() {
740
+ if (!isPlatformBrowser(this.platformId)) return
741
+ const { ConsentiSetup } = await import('@consenti/ui')
742
+ this.widget = new ConsentiSetup({ core: { regulation: 'gdpr' } })
743
+ }
744
+
745
+ ngOnDestroy() {
746
+ (this.widget as { destroy?: () => void })?.destroy?.()
747
+ }
748
+ }
749
+ ```
750
+
751
+ Or use the built-in Angular service:
752
+
753
+ ```ts
754
+ import { ConsentiService } from '@consenti/ui/angular'
755
+
756
+ @Component({ /* ... */ })
757
+ export class MyComponent {
758
+ consent = inject(ConsentiService)
759
+ // consent.hasConsent() | consent.showModal() | consent.getConsent()
760
+ }
761
+ ```
762
+
763
+ ### Vanilla JS
764
+
765
+ ```js
766
+ import { ConsentiSetup } from '@consenti/ui'
767
+ import '@consenti/ui/dist/index.css'
768
+
769
+ const widget = new ConsentiSetup({ core: { regulation: 'gdpr', locale: 'en' } })
770
+
771
+ document.querySelector('#cookie-settings')?.addEventListener('click', () => {
772
+ widget.showModal()
773
+ })
774
+ ```
775
+
776
+ ---
777
+
778
+ ## Plugins
779
+
780
+ ```ts
781
+ import { ConsentiPlugin, ConsentiSetup } from '@consenti/ui'
782
+
783
+ class MyPlugin extends ConsentiPlugin {
784
+ initialize(widget: ConsentiSetup) {
785
+ console.log('Consenti ready')
786
+ }
787
+
788
+ destroy() {}
789
+
790
+ onConsentSubmit(consent: Record<string, string>) {
791
+ fetch('/my-api/consent', { method: 'POST', body: JSON.stringify(consent) })
792
+ }
793
+
794
+ onBannerShow() {}
795
+ onBannerHide() {}
796
+ onModalShow() {}
797
+ onModalHide() {}
798
+ }
799
+
800
+ new ConsentiSetup({
801
+ core: { regulation: 'gdpr' },
802
+ plugins: [new MyPlugin()],
803
+ })
804
+ ```
805
+
806
+ ---
807
+
808
+ ## SSR / Next.js / Nuxt
809
+
810
+ ```ts
811
+ // Safe to call during SSR — silently no-ops, never touches the DOM:
812
+ const widget = new ConsentiSetup({ core: { regulation: 'gdpr' } })
813
+ // All browser API access is guarded by isClient() internally.
814
+ ```
815
+
816
+ ```ts
817
+ // SSR-safe React hook:
818
+ import { useConsent } from '@consenti/ui/react'
819
+ const { hasConsent } = useConsent() // returns false during SSR
820
+ ```
821
+
822
+ ---
823
+
824
+ ## Testing Utilities
825
+
826
+ ```ts
827
+ import {
828
+ mockAllGranted,
829
+ mockAllDenied,
830
+ simulateConsentSubmitted,
831
+ } from '@consenti/ui/testing'
832
+
833
+ mockAllGranted() // fake-grant all cookies for unit tests
834
+ mockAllDenied() // fake-deny all
835
+ simulateConsentSubmitted({ analytics: 'granted' })
836
+ ```
837
+
838
+ ---
839
+
840
+ ## Minimal Recipes
841
+
842
+ ```ts
843
+ // CCPA opt-out (no banner shown)
844
+ new ConsentiSetup({ core: { regulation: 'ccpa' } })
845
+
846
+ // Cross-subdomain consent
847
+ new ConsentiSetup({ core: { regulation: 'gdpr', storage: 'cookie', cookieDomains: '.example.com' } })
848
+
849
+ // Authenticated user — cross-device sync via API
850
+ new ConsentiSetup({ core: { regulation: 'gdpr', userId: '{{ server_user_id }}' }, api: { enabled: true } })
851
+
852
+ // GPC strict mode + GTM
853
+ new ConsentiSetup({
854
+ core: { regulation: 'gdpr', autoHonorGPC: 'strict' },
855
+ utils: { gtm: { containerId: 'GTM-XXXXXX', adsDataRedaction: true } },
856
+ })
857
+ ```
858
+
859
+ ---
860
+
861
+ ## TypeScript
862
+
863
+ All types are exported from the main entry:
864
+
865
+ ```ts
866
+ import type {
867
+ ConsentiConfig, // top-level config object
868
+ CoreConfig, // core section
869
+ ApiConfig, // api section
870
+ UtilsConfig, // utils section
871
+ GtmConfig, // utils.gtm section
872
+ ThemeConfig, // core.theme section
873
+ ConsentValue, // 'granted' | 'denied' | 'objected'
874
+ ConsentiProfile, // local profile class
875
+ } from '@consenti/ui'
876
+ ```
877
+
878
+ ---
879
+
880
+ ## Contributing
881
+
882
+ See [CONTRIBUTING.md](../../CONTRIBUTING.md) in the monorepo root.
883
+
884
+ ---
885
+
886
+ ## License
887
+
888
+ Apache 2.0 — see [LICENSE](../../LICENSE).