@nommos/core 0.0.39 → 0.0.42

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.
@@ -0,0 +1,828 @@
1
+ import { CMP_BROADCAST_CHANNEL, CMP_COOKIE_PREFIX, CMP_STORAGE_PREFIX, } from './constants';
2
+ import { createElement, escapeHtml, sanitizeCssColor, sanitizeCssFontFamily, sanitizeUrl } from './dom-safe';
3
+ import { resolveLanguage } from './i18n';
4
+ const ALL_CATEGORIES = ['necessary', 'analytics', 'sessionRecording', 'marketing'];
5
+ const OPTIONAL_CATEGORIES = ['analytics', 'sessionRecording', 'marketing'];
6
+ const DEFAULT_POLICY_URL = '/privacy-policy';
7
+ const CMP_COPY = {
8
+ en: {
9
+ bannerTitle: 'We value your privacy',
10
+ bannerDescription: 'We use cookies and similar technologies to enhance your experience, analyse site traffic, record sessions to understand how our platform is used, and support our marketing efforts. You can accept all, reject optional cookies, or customise your preferences.',
11
+ preferencesTitle: 'Customise preferences',
12
+ preferencesDescription: 'Manage your cookie preferences. You can enable or disable each category except those that are strictly necessary.',
13
+ manageTitle: 'Privacy preferences',
14
+ manageDescription: 'Manage your cookie and similar technology preferences. You can update your choices at any time.',
15
+ reconsentTitle: 'We have updated our privacy settings',
16
+ reconsentDescription: 'We have changed our privacy settings and published an updated policy. Please review your choices to continue.',
17
+ necessaryName: 'Necessary',
18
+ necessaryDescription: 'These cookies are essential for the website to function and cannot be disabled. They enable core features such as security, network management and accessibility.',
19
+ analyticsName: 'Analytics',
20
+ analyticsDescription: 'These cookies help us understand how visitors use our site, which pages are viewed and how people navigate, so that we can improve it.',
21
+ sessionRecordingName: 'Session recording',
22
+ sessionRecordingDescription: 'These cookies record interactions on our site, such as clicks and scrolling, to help us identify problems and improve usability.',
23
+ marketingName: 'Marketing',
24
+ marketingDescription: 'These cookies are used to show relevant advertising and to measure the effectiveness of our marketing campaigns across sites and channels.',
25
+ alwaysOn: 'Always on',
26
+ on: 'On',
27
+ off: 'Off',
28
+ acceptAll: 'Accept all',
29
+ rejectOptional: 'Reject optional',
30
+ customize: 'Customise',
31
+ savePreferences: 'Save preferences',
32
+ saveChanges: 'Save changes',
33
+ reviewChoices: 'Review choices',
34
+ manage: 'Manage',
35
+ close: 'Close',
36
+ learnMore: 'Learn more',
37
+ privacyChoices: 'Your privacy',
38
+ choicesActive: 'Your choices are active',
39
+ viewReceipt: 'View full receipt',
40
+ updatedNow: 'Updated just now',
41
+ browserSaveHint: 'Your choices will be saved in this browser.',
42
+ poweredBy: 'Powered by',
43
+ privacyPolicy: 'Privacy Policy',
44
+ cookiePolicy: 'Cookie Policy',
45
+ consentVersionLabel: 'Consent version',
46
+ whatsChanged: 'What has changed',
47
+ changeSummary: 'We have updated how we use cookies and similar technologies. These preferences help us improve performance, personalisation and product insight.',
48
+ expiredTitle: 'Your privacy choices have expired',
49
+ expiredDescription: 'It has been a while since you set your preferences. Please confirm your choices to continue.',
50
+ gpcNotice: 'Global Privacy Control',
51
+ gpcDescription: 'Your browser is sending a Global Privacy Control signal, so marketing is switched off.',
52
+ savedTitle: 'Your privacy preferences were saved',
53
+ savedDescription: 'Your settings are now active.',
54
+ updatedTitle: 'Preferences updated',
55
+ updatedDescription: 'Your privacy preferences were saved.',
56
+ settingsSaveError: 'We could not save your preferences. Please try again.',
57
+ configLoadError: 'Privacy settings could not be loaded.',
58
+ },
59
+ fr: {
60
+ bannerTitle: 'Nous respectons votre vie privée',
61
+ bannerDescription: 'Nous utilisons des cookies et des technologies similaires pour améliorer votre expérience, analyser le trafic, enregistrer les sessions afin de comprendre l’utilisation de notre plateforme et soutenir nos actions marketing. Vous pouvez tout accepter, refuser les cookies optionnels ou personnaliser vos préférences.',
62
+ preferencesTitle: 'Personnaliser les préférences',
63
+ preferencesDescription: 'Gérez vos préférences de cookies. Vous pouvez activer ou désactiver chaque catégorie, à l’exception de celles qui sont strictement nécessaires.',
64
+ manageTitle: 'Préférences de confidentialité',
65
+ manageDescription: 'Gérez vos préférences en matière de cookies et de technologies similaires. Vous pouvez modifier vos choix à tout moment.',
66
+ reconsentTitle: 'Nous avons mis à jour nos paramètres de confidentialité',
67
+ reconsentDescription: 'Nous avons modifié nos paramètres de confidentialité et publié une politique mise à jour. Veuillez vérifier vos choix pour continuer.',
68
+ necessaryName: 'Nécessaires',
69
+ necessaryDescription: 'Ces cookies sont indispensables au fonctionnement du site et ne peuvent pas être désactivés. Ils permettent des fonctions essentielles telles que la sécurité, la gestion du réseau et l’accessibilité.',
70
+ analyticsName: 'Mesure d’audience',
71
+ analyticsDescription: 'Ces cookies nous aident à comprendre comment les visiteurs utilisent notre site, quelles pages sont consultées et comment ils naviguent, afin de l’améliorer.',
72
+ sessionRecordingName: 'Enregistrement de session',
73
+ sessionRecordingDescription: 'Ces cookies enregistrent les interactions sur notre site, comme les clics et le défilement, pour nous aider à repérer les problèmes et améliorer l’ergonomie.',
74
+ marketingName: 'Marketing',
75
+ marketingDescription: 'Ces cookies servent à afficher des publicités pertinentes et à mesurer l’efficacité de nos campagnes marketing sur différents sites et canaux.',
76
+ alwaysOn: 'Toujours activé',
77
+ on: 'Activé',
78
+ off: 'Désactivé',
79
+ acceptAll: 'Tout accepter',
80
+ rejectOptional: 'Refuser les optionnels',
81
+ customize: 'Personnaliser',
82
+ savePreferences: 'Enregistrer les préférences',
83
+ saveChanges: 'Enregistrer les modifications',
84
+ reviewChoices: 'Vérifier mes choix',
85
+ manage: 'Gérer',
86
+ close: 'Fermer',
87
+ learnMore: 'En savoir plus',
88
+ privacyChoices: 'Votre confidentialité',
89
+ choicesActive: 'Vos choix sont actifs',
90
+ viewReceipt: 'Voir le reçu complet',
91
+ updatedNow: 'Mis à jour à l’instant',
92
+ browserSaveHint: 'Vos choix seront enregistrés dans ce navigateur.',
93
+ poweredBy: 'Propulsé par',
94
+ privacyPolicy: 'Politique de confidentialité',
95
+ cookiePolicy: 'Politique relative aux cookies',
96
+ consentVersionLabel: 'Version du consentement',
97
+ whatsChanged: 'Ce qui a changé',
98
+ changeSummary: 'Nous avons modifié notre utilisation des cookies et des technologies similaires. Ces préférences nous aident à améliorer les performances, la personnalisation et la connaissance de nos produits.',
99
+ expiredTitle: 'Vos choix de confidentialité ont expiré',
100
+ expiredDescription: 'Vos préférences datent d’un certain temps. Veuillez confirmer vos choix pour continuer.',
101
+ gpcNotice: 'Global Privacy Control',
102
+ gpcDescription: 'Votre navigateur envoie un signal Global Privacy Control : le marketing est donc désactivé.',
103
+ savedTitle: 'Vos préférences de confidentialité ont été enregistrées',
104
+ savedDescription: 'Vos paramètres sont désormais actifs.',
105
+ updatedTitle: 'Préférences mises à jour',
106
+ updatedDescription: 'Vos préférences de confidentialité ont été enregistrées.',
107
+ settingsSaveError: 'Nous n’avons pas pu enregistrer vos préférences. Veuillez réessayer.',
108
+ configLoadError: 'Les paramètres de confidentialité n’ont pas pu être chargés.',
109
+ },
110
+ es: {
111
+ bannerTitle: 'Valoramos tu privacidad',
112
+ bannerDescription: 'Utilizamos cookies y tecnologías similares para mejorar tu experiencia, analizar el tráfico del sitio, grabar sesiones para entender cómo se usa nuestra plataforma y apoyar nuestras acciones de marketing. Puedes aceptar todo, rechazar las cookies opcionales o personalizar tus preferencias.',
113
+ preferencesTitle: 'Personalizar preferencias',
114
+ preferencesDescription: 'Gestiona tus preferencias de cookies. Puedes activar o desactivar cada categoría, excepto las estrictamente necesarias.',
115
+ manageTitle: 'Preferencias de privacidad',
116
+ manageDescription: 'Gestiona tus preferencias de cookies y tecnologías similares. Puedes actualizar tus decisiones en cualquier momento.',
117
+ reconsentTitle: 'Hemos actualizado nuestra configuración de privacidad',
118
+ reconsentDescription: 'Hemos cambiado nuestra configuración de privacidad y publicado una política actualizada. Revisa tus opciones para continuar.',
119
+ necessaryName: 'Necesarias',
120
+ necessaryDescription: 'Estas cookies son esenciales para que el sitio funcione y no se pueden desactivar. Habilitan funciones básicas como la seguridad, la gestión de red y la accesibilidad.',
121
+ analyticsName: 'Analítica',
122
+ analyticsDescription: 'Estas cookies nos ayudan a entender cómo usan nuestro sitio los visitantes, qué páginas se ven y cómo navegan, para poder mejorarlo.',
123
+ sessionRecordingName: 'Grabación de sesión',
124
+ sessionRecordingDescription: 'Estas cookies registran las interacciones en nuestro sitio, como clics y desplazamientos, para ayudarnos a detectar problemas y mejorar la usabilidad.',
125
+ marketingName: 'Marketing',
126
+ marketingDescription: 'Estas cookies se utilizan para mostrar publicidad relevante y medir la eficacia de nuestras campañas de marketing en distintos sitios y canales.',
127
+ alwaysOn: 'Siempre activo',
128
+ on: 'Activado',
129
+ off: 'Desactivado',
130
+ acceptAll: 'Aceptar todo',
131
+ rejectOptional: 'Rechazar opcionales',
132
+ customize: 'Personalizar',
133
+ savePreferences: 'Guardar preferencias',
134
+ saveChanges: 'Guardar cambios',
135
+ reviewChoices: 'Revisar opciones',
136
+ manage: 'Gestionar',
137
+ close: 'Cerrar',
138
+ learnMore: 'Más información',
139
+ privacyChoices: 'Tu privacidad',
140
+ choicesActive: 'Tus opciones están activas',
141
+ viewReceipt: 'Ver el recibo completo',
142
+ updatedNow: 'Actualizado ahora mismo',
143
+ browserSaveHint: 'Tus opciones se guardarán en este navegador.',
144
+ poweredBy: 'Con la tecnología de',
145
+ privacyPolicy: 'Política de privacidad',
146
+ cookiePolicy: 'Política de cookies',
147
+ consentVersionLabel: 'Versión del consentimiento',
148
+ whatsChanged: 'Qué ha cambiado',
149
+ changeSummary: 'Hemos actualizado cómo usamos las cookies y tecnologías similares. Estas preferencias nos ayudan a mejorar el rendimiento, la personalización y el conocimiento del producto.',
150
+ expiredTitle: 'Tus opciones de privacidad han caducado',
151
+ expiredDescription: 'Ha pasado un tiempo desde que configuraste tus preferencias. Confirma tus opciones para continuar.',
152
+ gpcNotice: 'Global Privacy Control',
153
+ gpcDescription: 'Tu navegador envía una señal de Global Privacy Control, por lo que marketing está desactivado.',
154
+ savedTitle: 'Tus preferencias de privacidad se han guardado',
155
+ savedDescription: 'Tu configuración ya está activa.',
156
+ updatedTitle: 'Preferencias actualizadas',
157
+ updatedDescription: 'Tus preferencias de privacidad se han guardado.',
158
+ settingsSaveError: 'No hemos podido guardar tus preferencias. Inténtalo de nuevo.',
159
+ configLoadError: 'No se ha podido cargar la configuración de privacidad.',
160
+ },
161
+ };
162
+ /** Opt-out model: a visitor who has not decided yet counts as having granted everything. */
163
+ function allGranted() {
164
+ return { necessary: true, analytics: true, sessionRecording: true, marketing: true };
165
+ }
166
+ /** Every optional category refused; `necessary` is not refusable. */
167
+ function optionalRefused() {
168
+ return { necessary: true, analytics: false, sessionRecording: false, marketing: false };
169
+ }
170
+ export class ConsentManager {
171
+ constructor(siteKey, subjectId, cmpEndpoint, callbacks) {
172
+ this.siteKey = siteKey;
173
+ this.subjectId = subjectId;
174
+ this.cmpEndpoint = cmpEndpoint;
175
+ this.callbacks = callbacks;
176
+ this.cmpConfig = null;
177
+ this.currentState = null;
178
+ this.root = null;
179
+ this.toast = null;
180
+ this.summary = null;
181
+ this.channel = null;
182
+ this.storageListener = (event) => {
183
+ if (event.key !== this.storageKey || !event.newValue)
184
+ return;
185
+ try {
186
+ this.handleExternalState(JSON.parse(event.newValue));
187
+ }
188
+ catch {
189
+ // Ignore malformed cross-tab payloads.
190
+ }
191
+ };
192
+ this.draft = allGranted();
193
+ const key = this.storageSafeKey(siteKey);
194
+ this.storageKey = `${CMP_STORAGE_PREFIX}${key}`;
195
+ this.cookieName = `${CMP_COOKIE_PREFIX}${key}`;
196
+ if (typeof BroadcastChannel !== 'undefined') {
197
+ this.channel = new BroadcastChannel(CMP_BROADCAST_CHANNEL);
198
+ this.channel.onmessage = (event) => this.handleExternalState(event.data);
199
+ }
200
+ if (typeof window !== 'undefined') {
201
+ window.addEventListener('storage', this.storageListener);
202
+ }
203
+ }
204
+ async init() {
205
+ if (typeof window === 'undefined' || typeof document === 'undefined')
206
+ return;
207
+ try {
208
+ const config = await this.fetchConfig();
209
+ if (!config?.enabled) {
210
+ this.callbacks.onConsentProof(null);
211
+ this.callbacks.onSessionRecordingRevoked();
212
+ this.callbacks.onMarketingRevoked();
213
+ return;
214
+ }
215
+ this.cmpConfig = config;
216
+ this.installGlobalApi();
217
+ this.injectStyles(config);
218
+ const stored = this.loadStoredConsent();
219
+ const expired = this.isExpired(stored);
220
+ if (stored && stored.configVersion === config.version && stored.consentRef && !expired) {
221
+ this.currentState = stored;
222
+ this.applyConsent(stored.permissions, stored);
223
+ this.renderPersistentPrivacyControl();
224
+ return;
225
+ }
226
+ this.callbacks.onConsentProof(null);
227
+ // No usable decision yet. Under the opt-out model the visitor is treated as having
228
+ // granted everything, minus anything a GPC signal withdraws.
229
+ this.applyOptionalPermissions(allGranted());
230
+ // An expired decision is re-collected through the same flow as a policy version change:
231
+ // the visitor is shown what they previously chose and asked to confirm it.
232
+ if (stored && (expired || stored.configVersion !== config.version)) {
233
+ this.renderReconsent(stored.permissions, expired);
234
+ }
235
+ else {
236
+ this.renderBanner();
237
+ }
238
+ }
239
+ catch (error) {
240
+ this.callbacks.onConsentProof(null);
241
+ this.callbacks.onSessionRecordingRevoked();
242
+ this.callbacks.onMarketingRevoked();
243
+ // Always surfaced, not gated behind dev mode: this means the visitor is browsing with no
244
+ // valid consent state and no banner, which the site owner needs to know about.
245
+ console.warn('[Nommos CMP] Consent manager failed to initialise; no consent UI is shown.', error);
246
+ }
247
+ }
248
+ openPreferences() {
249
+ if (!this.cmpConfig)
250
+ return;
251
+ this.draft = this.currentState?.permissions ?? allGranted();
252
+ this.renderPreferencePanel('manage');
253
+ }
254
+ /** True when a stored decision carries an expiry that has passed. */
255
+ isExpired(stored) {
256
+ if (!stored?.expiresAt)
257
+ return false;
258
+ const expiry = Date.parse(stored.expiresAt);
259
+ return Number.isFinite(expiry) && expiry <= Date.now();
260
+ }
261
+ getState() {
262
+ return this.currentState;
263
+ }
264
+ destroy() {
265
+ this.root?.remove();
266
+ this.toast?.remove();
267
+ this.summary?.remove();
268
+ this.channel?.close();
269
+ window.removeEventListener('storage', this.storageListener);
270
+ if (window.NommosConsent?.getState() === this.getState()) {
271
+ delete window.NommosConsent;
272
+ }
273
+ }
274
+ async fetchConfig() {
275
+ const res = await fetch(`${this.cmpEndpoint}/${encodeURIComponent(this.siteKey)}`);
276
+ if (!res.ok) {
277
+ throw new Error(`CMP config request failed: ${res.status}`);
278
+ }
279
+ return res.json();
280
+ }
281
+ async saveConsent(action, permissions) {
282
+ if (!this.cmpConfig)
283
+ return;
284
+ const normalized = this.applyGlobalPrivacyControl(permissions);
285
+ const res = await fetch(`${this.cmpEndpoint}/${encodeURIComponent(this.siteKey)}/consent-events`, {
286
+ method: 'POST',
287
+ headers: { 'content-type': 'application/json' },
288
+ // configVersion and policyHash are deliberately not sent: the server owns them, and a
289
+ // receipt whose audit metadata came from the client would prove nothing.
290
+ body: JSON.stringify({
291
+ eventType: action,
292
+ subjectId: this.subjectId,
293
+ previousReceiptId: this.currentState?.receiptId,
294
+ language: this.language(),
295
+ clientTimestamp: new Date().toISOString(),
296
+ gpcApplied: this.hasGlobalPrivacyControl(),
297
+ permissions: normalized,
298
+ }),
299
+ });
300
+ if (!res.ok) {
301
+ console.warn(`[Nommos CMP] Consent could not be saved (HTTP ${res.status}); the visitor's choice was not recorded.`);
302
+ this.showToast(this.text('updatedTitle'), this.text('settingsSaveError', 'Could not save your preferences. Please try again.'), true);
303
+ return;
304
+ }
305
+ const response = await res.json();
306
+ const stored = {
307
+ receiptId: response.receiptId,
308
+ consentRef: response.consentRef,
309
+ configVersion: response.configVersion,
310
+ policyHash: response.policyHash,
311
+ permissions: response.permissions,
312
+ effectiveAt: response.effectiveAt,
313
+ expiresAt: response.expiresAt ?? null,
314
+ gpcApplied: Boolean(response.gpcApplied),
315
+ };
316
+ this.persistConsent(stored);
317
+ this.currentState = stored;
318
+ this.applyConsent(stored.permissions, stored);
319
+ this.broadcastState(stored);
320
+ this.closeRoot();
321
+ this.renderPersistentPrivacyControl();
322
+ this.showToast(action === 'SAVE_PREFERENCES' || action === 'RECONSENT' ? this.text('updatedTitle') : this.text('savedTitle'), action === 'SAVE_PREFERENCES' || action === 'RECONSENT' ? this.text('updatedDescription') : this.text('savedDescription'));
323
+ }
324
+ applyConsent(permissions, state) {
325
+ this.callbacks.onConsentProof(state);
326
+ this.applyOptionalPermissions(permissions);
327
+ }
328
+ applyOptionalPermissions(permissions) {
329
+ const normalized = this.applyGlobalPrivacyControl(permissions);
330
+ if (normalized.analytics) {
331
+ this.callbacks.onAnalyticsGranted();
332
+ }
333
+ else {
334
+ this.callbacks.onAnalyticsRevoked();
335
+ }
336
+ if (normalized.sessionRecording) {
337
+ this.callbacks.onSessionRecordingGranted();
338
+ }
339
+ else {
340
+ this.callbacks.onSessionRecordingRevoked();
341
+ }
342
+ if (normalized.marketing) {
343
+ this.callbacks.onMarketingGranted();
344
+ }
345
+ else {
346
+ this.callbacks.onMarketingRevoked();
347
+ }
348
+ }
349
+ loadStoredConsent() {
350
+ const raw = localStorage.getItem(this.storageKey);
351
+ if (!raw)
352
+ return null;
353
+ try {
354
+ const parsed = JSON.parse(raw);
355
+ if (!parsed?.receiptId || !parsed?.consentRef || !parsed?.permissions) {
356
+ localStorage.removeItem(this.storageKey);
357
+ return null;
358
+ }
359
+ return parsed;
360
+ }
361
+ catch (error) {
362
+ localStorage.removeItem(this.storageKey);
363
+ // Not dev-gated: a discarded decision means the visitor is about to be re-prompted, and
364
+ // repeated occurrences point at a storage or serialisation bug worth seeing.
365
+ console.warn('[Nommos CMP] Stored consent state was unreadable and has been discarded.', error);
366
+ return null;
367
+ }
368
+ }
369
+ persistConsent(state) {
370
+ localStorage.setItem(this.storageKey, JSON.stringify(state));
371
+ document.cookie = `${this.cookieName}=${encodeURIComponent(state.consentRef)}; path=/; SameSite=Lax`;
372
+ }
373
+ broadcastState(state) {
374
+ this.channel?.postMessage(state);
375
+ localStorage.setItem(`${this.storageKey}:updated`, String(Date.now()));
376
+ }
377
+ handleExternalState(data) {
378
+ if (!data || typeof data !== 'object')
379
+ return;
380
+ const state = data;
381
+ if (!state.receiptId || state.receiptId === this.currentState?.receiptId)
382
+ return;
383
+ this.currentState = state;
384
+ this.applyConsent(state.permissions, state);
385
+ this.closeRoot();
386
+ this.renderPersistentPrivacyControl();
387
+ }
388
+ renderBanner() {
389
+ if (!this.cmpConfig)
390
+ return;
391
+ this.draft = this.applyGlobalPrivacyControl(allGranted());
392
+ this.root = this.createRoot('nommos-cmp-root nommos-cmp-banner-root');
393
+ this.root.innerHTML = `
394
+ <section class="nommos-cmp-banner" role="dialog" aria-modal="false" aria-labelledby="nommos-cmp-title">
395
+ ${this.logoBlock()}
396
+ <div class="nommos-cmp-banner-copy">
397
+ <h2 id="nommos-cmp-title">${this.text('bannerTitle')}</h2>
398
+ <p>${this.text('bannerDescription')}</p>
399
+ <div class="nommos-cmp-chip-row">
400
+ ${this.enabledCategories().map((category) => this.permissionChip(category, this.draft[category] !== false)).join('')}
401
+ </div>
402
+ </div>
403
+ <div class="nommos-cmp-banner-actions">
404
+ <button class="nommos-cmp-btn nommos-cmp-secondary" data-cmp-action="customize">${this.iconGear()}${this.text('customize')}</button>
405
+ <button class="nommos-cmp-btn nommos-cmp-secondary" data-cmp-action="reject">${this.iconX()}${this.text('rejectOptional')}</button>
406
+ <button class="nommos-cmp-btn nommos-cmp-primary" data-cmp-action="accept">${this.iconCheck()}${this.text('acceptAll')}</button>
407
+ </div>
408
+ </section>
409
+ ${this.floatingHtml()}
410
+ `;
411
+ this.finalizeRoot();
412
+ this.bindCommonActions();
413
+ }
414
+ renderPreferencePanel(mode) {
415
+ if (!this.cmpConfig)
416
+ return;
417
+ this.root = this.createRoot('nommos-cmp-root nommos-cmp-panel-root');
418
+ const title = mode === 'manage' ? this.text('manageTitle') : this.text('preferencesTitle');
419
+ const description = mode === 'manage' ? this.text('manageDescription') : this.text('preferencesDescription');
420
+ this.root.innerHTML = `
421
+ <div class="nommos-cmp-scrim"></div>
422
+ <aside class="nommos-cmp-panel" role="dialog" aria-modal="true" aria-labelledby="nommos-cmp-panel-title">
423
+ <button class="nommos-cmp-close" type="button" aria-label="${this.text('close', 'Close')}" data-cmp-action="close">${this.iconX()}</button>
424
+ <div class="nommos-cmp-panel-head">
425
+ <span class="nommos-cmp-shield">${this.iconShield()}</span>
426
+ <div>
427
+ <h2 id="nommos-cmp-panel-title">${title}</h2>
428
+ <p>${description}</p>
429
+ </div>
430
+ </div>
431
+ <div class="nommos-cmp-pref-list">
432
+ ${this.enabledCategories().map((category) => this.preferenceRow(category)).join('')}
433
+ </div>
434
+ <footer class="nommos-cmp-panel-footer">
435
+ <button class="nommos-cmp-btn nommos-cmp-secondary" data-cmp-action="accept">${this.iconCheck()}${this.text('acceptAll')}</button>
436
+ <button class="nommos-cmp-btn nommos-cmp-secondary" data-cmp-action="reject">${this.iconX()}${this.text('rejectOptional')}</button>
437
+ <button class="nommos-cmp-btn nommos-cmp-primary" data-cmp-action="save">${mode === 'manage' ? this.text('saveChanges') : this.text('savePreferences')}</button>
438
+ <div class="nommos-cmp-panel-meta">
439
+ ${this.policyLinks()}
440
+ ${this.poweredBy()}
441
+ </div>
442
+ </footer>
443
+ </aside>
444
+ ${this.floatingHtml()}
445
+ `;
446
+ this.finalizeRoot();
447
+ this.bindCommonActions();
448
+ this.bindToggles();
449
+ }
450
+ renderReconsent(previous, expired = false) {
451
+ if (!this.cmpConfig)
452
+ return;
453
+ this.draft = this.applyGlobalPrivacyControl(previous);
454
+ const title = expired ? this.text('expiredTitle') : this.text('reconsentTitle');
455
+ const description = expired ? this.text('expiredDescription') : this.text('reconsentDescription');
456
+ this.root = this.createRoot('nommos-cmp-root nommos-cmp-reconsent-root');
457
+ this.root.innerHTML = `
458
+ <div class="nommos-cmp-reconsent-scrim"></div>
459
+ <section class="nommos-cmp-reconsent" role="dialog" aria-modal="true" aria-labelledby="nommos-cmp-reconsent-title">
460
+ <button class="nommos-cmp-close" type="button" aria-label="${this.text('close', 'Close')}" data-cmp-action="close">${this.iconX()}</button>
461
+ <div class="nommos-cmp-reconsent-icon">${this.iconShield()}</div>
462
+ <h2 id="nommos-cmp-reconsent-title">${title}</h2>
463
+ <p>${description}</p>
464
+ <div class="nommos-cmp-version-row">
465
+ <span>${this.text('consentVersionLabel', 'Consent version')} ${escapeHtml(this.cmpConfig.version)}</span>
466
+ <span>${escapeHtml(new Date().toLocaleDateString(this.language()))}</span>
467
+ </div>
468
+ <div class="nommos-cmp-change">
469
+ <strong>${this.text('whatsChanged')}</strong>
470
+ <p>${this.text('changeSummary')}</p>
471
+ </div>
472
+ <div class="nommos-cmp-chip-row nommos-cmp-chip-row-center">
473
+ ${this.enabledCategories().map((category) => this.permissionChip(category, this.draft[category] !== false)).join('')}
474
+ </div>
475
+ <div class="nommos-cmp-reconsent-actions">
476
+ <button class="nommos-cmp-btn nommos-cmp-primary" data-cmp-action="accept">${this.iconCheck()}${this.text('acceptAll')}</button>
477
+ <button class="nommos-cmp-btn nommos-cmp-secondary" data-cmp-action="reject">${this.iconX()}${this.text('rejectOptional')}</button>
478
+ <button class="nommos-cmp-btn nommos-cmp-secondary" data-cmp-action="customize">${this.text('reviewChoices')}</button>
479
+ </div>
480
+ ${this.policyLinks()}
481
+ ${this.poweredBy()}
482
+ </section>
483
+ ${this.floatingHtml()}
484
+ `;
485
+ this.finalizeRoot();
486
+ this.bindCommonActions(true);
487
+ }
488
+ renderFloatingControl() {
489
+ if (this.root || !this.cmpConfig || !this.shouldShowPersistentPrivacyControl())
490
+ return;
491
+ this.root = this.createRoot('nommos-cmp-root nommos-cmp-floating-root');
492
+ this.root.innerHTML = this.floatingHtml();
493
+ this.finalizeRoot();
494
+ this.bindCommonActions();
495
+ }
496
+ renderSummaryBar() {
497
+ if (!this.cmpConfig || !this.currentState || !this.shouldShowPersistentPrivacyControl())
498
+ return;
499
+ this.summary?.remove();
500
+ const summary = document.createElement('div');
501
+ summary.className = 'nommos-cmp-summary';
502
+ const permissions = this.currentState.permissions;
503
+ summary.innerHTML = `
504
+ <strong>${this.text('privacyChoices')}</strong>
505
+ ${this.enabledCategories().map((category) => {
506
+ const state = category === 'necessary'
507
+ ? this.text('alwaysOn')
508
+ : permissions[category] ? this.text('on') : this.text('off');
509
+ return `<span>${this.categoryIcon(category)} ${this.categoryName(category)} <em>${state}</em></span>`;
510
+ }).join('')}
511
+ <button type="button" data-cmp-action="manage">${this.text('manage', 'Manage')}</button>
512
+ `;
513
+ document.body.appendChild(summary);
514
+ this.summary = summary;
515
+ summary.querySelector('[data-cmp-action="manage"]')?.addEventListener('click', () => this.openPreferences());
516
+ }
517
+ renderPersistentPrivacyControl() {
518
+ this.summary?.remove();
519
+ this.summary = null;
520
+ if (!this.shouldShowPersistentPrivacyControl())
521
+ return;
522
+ this.renderFloatingControl();
523
+ this.renderSummaryBar();
524
+ }
525
+ showToast(title, message, error = false) {
526
+ this.toast?.remove();
527
+ const toast = document.createElement('div');
528
+ toast.className = `nommos-cmp-toast${error ? ' nommos-cmp-toast-error' : ''}`;
529
+ toast.innerHTML = `
530
+ <span class="nommos-cmp-toast-icon">${error ? this.iconX() : this.iconCheck()}</span>
531
+ <span><strong>${title}</strong><small>${message}</small></span>
532
+ <button type="button" aria-label="Close">${this.iconX()}</button>
533
+ `;
534
+ document.body.appendChild(toast);
535
+ this.toast = toast;
536
+ toast.querySelector('button')?.addEventListener('click', () => toast.remove());
537
+ setTimeout(() => toast.remove(), 5000);
538
+ }
539
+ bindCommonActions(isReconsent = false) {
540
+ this.root?.querySelector('[data-cmp-action="accept"]')?.addEventListener('click', () => {
541
+ this.saveConsent(isReconsent ? 'RECONSENT' : 'ACCEPT_ALL', allGranted());
542
+ });
543
+ this.root?.querySelector('[data-cmp-action="reject"]')?.addEventListener('click', () => {
544
+ this.saveConsent(isReconsent ? 'RECONSENT' : 'REJECT_OPTIONAL', optionalRefused());
545
+ });
546
+ this.root?.querySelector('[data-cmp-action="customize"]')?.addEventListener('click', () => this.renderPreferencePanel('customize'));
547
+ this.root?.querySelector('[data-cmp-action="privacy"]')?.addEventListener('click', () => this.openPreferences());
548
+ this.root?.querySelector('[data-cmp-action="close"]')?.addEventListener('click', () => {
549
+ if (this.currentState) {
550
+ this.closeRoot();
551
+ this.renderPersistentPrivacyControl();
552
+ }
553
+ else if (this.root?.classList.contains('nommos-cmp-panel-root')) {
554
+ this.renderBanner();
555
+ }
556
+ });
557
+ this.root?.querySelector('[data-cmp-action="save"]')?.addEventListener('click', () => this.saveConsent('SAVE_PREFERENCES', this.draft));
558
+ }
559
+ bindToggles() {
560
+ this.root?.querySelectorAll('input[data-cmp-permission]').forEach((input) => {
561
+ input.addEventListener('change', () => {
562
+ const category = input.dataset['cmpPermission'];
563
+ if (category && OPTIONAL_CATEGORIES.includes(category)) {
564
+ this.draft = { ...this.draft, [category]: input.checked };
565
+ }
566
+ });
567
+ });
568
+ }
569
+ /**
570
+ * Every category, in display order. The backend's per-category `enabled` flag is a tenant
571
+ * activation setting — whether that consent, once granted, is actually stored and enforced —
572
+ * not a UI visibility switch. A visitor can express a preference for a category the tenant
573
+ * hasn't activated yet; the backend clamps the *effect* of that preference independently.
574
+ */
575
+ enabledCategories() {
576
+ return ALL_CATEGORIES;
577
+ }
578
+ /**
579
+ * Whether the tenant has switched this category on — the other half of the server's rule that a
580
+ * permission needs both tenant activation and a visitor grant.
581
+ *
582
+ * <p>Deliberately not used to filter {@link enabledCategories}: a visitor may still express a
583
+ * preference for a category the tenant has not activated. It exists for callers that would
584
+ * otherwise ask the server for something it is certain to refuse with `FEATURE_DISABLED`.
585
+ */
586
+ isCategoryActivated(category) {
587
+ return this.cmpConfig?.permissions?.[category]?.enabled !== false;
588
+ }
589
+ preferenceRow(category) {
590
+ const locked = category === 'necessary';
591
+ const enabled = locked ? true : Boolean(this.draft[category]);
592
+ const disabledByGpc = category === 'marketing' && this.hasGlobalPrivacyControl();
593
+ return `
594
+ <article class="nommos-cmp-pref-row">
595
+ <span class="nommos-cmp-pref-icon">${this.categoryIcon(category)}</span>
596
+ <div>
597
+ <h3>${this.categoryName(category)} ${locked ? `<span>${this.text('alwaysOn')}</span>` : ''}</h3>
598
+ <p>${this.categoryDescription(category)}</p>
599
+ ${disabledByGpc ? `<a title="${this.text('gpcDescription')}">${this.text('gpcNotice')}</a>` : `<a>${this.text('learnMore')}</a>`}
600
+ </div>
601
+ ${locked
602
+ ? `<span class="nommos-cmp-lock">${this.iconLock()}</span>`
603
+ : `<label class="nommos-cmp-toggle"><input type="checkbox" data-cmp-permission="${escapeHtml(category)}" ${enabled && !disabledByGpc ? 'checked' : ''} ${disabledByGpc ? 'disabled' : ''}/><span></span></label>`}
604
+ </article>
605
+ `;
606
+ }
607
+ permissionChip(category, enabled) {
608
+ const locked = category === 'necessary';
609
+ return `
610
+ <div class="nommos-cmp-chip">
611
+ <span>${this.categoryIcon(category)}</span>
612
+ <strong>${this.categoryName(category)}</strong>
613
+ <em>${locked ? this.text('alwaysOn') : enabled ? this.text('on') : this.text('off')}</em>
614
+ ${locked ? this.iconLock() : enabled ? this.iconCheckCircle() : this.iconMinusCircle()}
615
+ </div>
616
+ `;
617
+ }
618
+ createRoot(className) {
619
+ this.closeRoot();
620
+ const root = document.createElement('div');
621
+ root.className = className;
622
+ document.body.appendChild(root);
623
+ return root;
624
+ }
625
+ /**
626
+ * Run after any innerHTML assignment on the root: replaces logo placeholders with elements
627
+ * built via DOM APIs, so tenant-supplied branding never passes through markup parsing.
628
+ */
629
+ finalizeRoot() {
630
+ if (this.root)
631
+ this.hydrateLogos(this.root);
632
+ }
633
+ closeRoot() {
634
+ this.root?.remove();
635
+ this.root = null;
636
+ }
637
+ installGlobalApi() {
638
+ window.NommosConsent = {
639
+ openPreferences: () => this.openPreferences(),
640
+ getState: () => this.getState(),
641
+ };
642
+ }
643
+ /** A Global Privacy Control signal only forces Marketing off; other opt-out categories stay user-controlled. */
644
+ applyGlobalPrivacyControl(permissions) {
645
+ if (this.hasGlobalPrivacyControl()) {
646
+ return { ...permissions, necessary: true, marketing: false };
647
+ }
648
+ return {
649
+ necessary: true,
650
+ analytics: Boolean(permissions.analytics),
651
+ sessionRecording: Boolean(permissions.sessionRecording),
652
+ marketing: Boolean(permissions.marketing),
653
+ };
654
+ }
655
+ hasGlobalPrivacyControl() {
656
+ return Boolean(this.cmpConfig?.settings?.honorGlobalPrivacyControl && navigator.globalPrivacyControl);
657
+ }
658
+ shouldShowPersistentPrivacyControl() {
659
+ return this.cmpConfig?.settings?.showFloatingPrivacyControl === true;
660
+ }
661
+ /**
662
+ * The raw configured value. Only for callers that sanitise it themselves — everything that
663
+ * lands in markup must go through {@link text} or {@link url} instead.
664
+ */
665
+ rawText(key, fallback = '') {
666
+ const lang = this.language();
667
+ const local = CMP_COPY[lang]?.[key] ?? CMP_COPY.en[key];
668
+ if (local !== undefined)
669
+ return local;
670
+ const entry = this.cmpConfig?.content?.[key];
671
+ if (entry === undefined || entry === null)
672
+ return fallback;
673
+ if (typeof entry === 'string')
674
+ return entry;
675
+ return entry[lang] ?? entry['en'] ?? fallback;
676
+ }
677
+ /**
678
+ * Content escaped for interpolation into markup.
679
+ *
680
+ * Escaping here rather than at each of the ~40 call sites means a newly added string is safe
681
+ * by default; the unsafe path has to be asked for explicitly.
682
+ */
683
+ text(key, fallback = '') {
684
+ return escapeHtml(this.rawText(key, fallback));
685
+ }
686
+ /** Content sanitised for use in `href`/`src`. Returns '' when the URL is not safe. */
687
+ url(key, fallback = '') {
688
+ return sanitizeUrl(this.rawText(key, fallback));
689
+ }
690
+ language() {
691
+ return resolveLanguage();
692
+ }
693
+ /**
694
+ * Built with DOM APIs rather than interpolated markup: `logoUrl` and `companyName` come from
695
+ * tenant configuration, and this renders on the tenant's own customers' pages.
696
+ */
697
+ logoElement() {
698
+ const brand = this.cmpConfig?.branding;
699
+ const container = createElement('div', { className: 'nommos-cmp-logo' });
700
+ const companyName = brand?.companyName ?? 'NOMMOS';
701
+ const safeLogo = sanitizeUrl(brand?.logoUrl);
702
+ if (safeLogo) {
703
+ container.appendChild(createElement('img', { attributes: { src: safeLogo, alt: companyName } }));
704
+ return container;
705
+ }
706
+ container.innerHTML = this.iconBars();
707
+ container.appendChild(createElement('strong', { text: companyName }));
708
+ return container;
709
+ }
710
+ /** Placeholder replaced with {@link logoElement} after the markup is parsed. */
711
+ logoBlock() {
712
+ return '<div data-cmp-logo-slot></div>';
713
+ }
714
+ /** Swaps every logo placeholder for a safely constructed element. */
715
+ hydrateLogos(root) {
716
+ root.querySelectorAll('[data-cmp-logo-slot]').forEach((slot) => {
717
+ slot.replaceWith(this.logoElement());
718
+ });
719
+ }
720
+ floatingHtml() {
721
+ if (!this.shouldShowPersistentPrivacyControl())
722
+ return '';
723
+ return `<button class="nommos-cmp-floating" type="button" data-cmp-action="privacy">${this.iconShield()}<span>${this.text('privacyChoices')}</span></button>`;
724
+ }
725
+ policyLinks() {
726
+ return `
727
+ <div class="nommos-cmp-links">
728
+ <a href="${escapeHtml(this.configUrl('privacyPolicyUrl'))}" target="_blank" rel="noopener">${this.text('privacyPolicy')}</a>
729
+ <span></span>
730
+ <a href="${escapeHtml(this.configUrl('cookiePolicyUrl'))}" target="_blank" rel="noopener">${this.text('cookiePolicy')}</a>
731
+ </div>
732
+ `;
733
+ }
734
+ configUrl(key) {
735
+ const value = sanitizeUrl(this.cmpConfig?.[key] ?? '');
736
+ if (value)
737
+ return value;
738
+ return this.url(key, DEFAULT_POLICY_URL) || DEFAULT_POLICY_URL;
739
+ }
740
+ poweredBy() {
741
+ return `<div class="nommos-cmp-powered">${this.text('poweredBy')} <strong>NOMMOS</strong></div>`;
742
+ }
743
+ // Content keys are derived from the category name, so adding a category needs only new
744
+ // entries in the server-side defaults rather than another branch in three parallel chains.
745
+ categoryName(category) {
746
+ return this.text(`${category}Name`);
747
+ }
748
+ categoryDescription(category) {
749
+ return this.text(`${category}Description`);
750
+ }
751
+ categoryIcon(category) {
752
+ switch (category) {
753
+ case 'necessary':
754
+ return this.iconShield();
755
+ case 'analytics':
756
+ return this.iconBars();
757
+ case 'sessionRecording':
758
+ return this.iconScreen();
759
+ default:
760
+ return this.iconMegaphone();
761
+ }
762
+ }
763
+ storageSafeKey(value) {
764
+ return value.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80);
765
+ }
766
+ iconShield() {
767
+ return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l7 3v5c0 4.6-2.9 8.7-7 10-4.1-1.3-7-5.4-7-10V6l7-3z" fill="none" stroke="currentColor" stroke-width="2"/><path d="M9 12l2 2 4-5" fill="none" stroke="currentColor" stroke-width="2"/></svg>';
768
+ }
769
+ iconScreen() {
770
+ return '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="5" width="18" height="12" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M8 21h8M12 17v4" stroke="currentColor" stroke-width="2"/></svg>';
771
+ }
772
+ iconMegaphone() {
773
+ return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 14h3l9 4V6L7 10H4v4z" fill="none" stroke="currentColor" stroke-width="2"/><path d="M7 14l2 6" stroke="currentColor" stroke-width="2"/></svg>';
774
+ }
775
+ iconBars() {
776
+ return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 20V9M12 20V4M19 20v-7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></svg>';
777
+ }
778
+ iconCheck() {
779
+ return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12l4 4L19 6" fill="none" stroke="currentColor" stroke-width="2.5"/></svg>';
780
+ }
781
+ iconX() {
782
+ return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/></svg>';
783
+ }
784
+ iconGear() {
785
+ return '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 2v3M12 19v3M4.9 4.9L7 7M17 17l2.1 2.1M2 12h3M19 12h3M4.9 19.1L7 17M17 7l2.1-2.1" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
786
+ }
787
+ iconLock() {
788
+ return '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="10" width="12" height="10" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M8 10V7a4 4 0 018 0v3" fill="none" stroke="currentColor" stroke-width="2"/></svg>';
789
+ }
790
+ iconCheckCircle() {
791
+ return '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2"/><path d="M8 12l3 3 5-6" fill="none" stroke="currentColor" stroke-width="2"/></svg>';
792
+ }
793
+ iconMinusCircle() {
794
+ return '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2"/><path d="M8 12h8" stroke="currentColor" stroke-width="2"/></svg>';
795
+ }
796
+ injectStyles(config) {
797
+ if (document.getElementById('nommos-cmp-styles'))
798
+ return;
799
+ // Branding is tenant-controlled and lands inside a <style> block, so it is validated
800
+ // rather than trusted — see sanitizeCssColor for what an unchecked value permits.
801
+ const primary = sanitizeCssColor(config.branding.primaryColor, '#3155F5');
802
+ const secondary = sanitizeCssColor(config.branding.secondaryColor, primary);
803
+ const font = sanitizeCssFontFamily(config.branding.fontFamily, 'Inter, Arial, sans-serif');
804
+ const style = document.createElement('style');
805
+ style.id = 'nommos-cmp-styles';
806
+ style.textContent = `
807
+ .nommos-cmp-root,.nommos-cmp-root *,.nommos-cmp-toast,.nommos-cmp-toast *,.nommos-cmp-summary,.nommos-cmp-summary *{box-sizing:border-box;font-family:${font};letter-spacing:0}
808
+ .nommos-cmp-banner{position:fixed;left:clamp(14px,2vw,32px);right:clamp(14px,2vw,32px);bottom:24px;z-index:2147483000;display:grid;grid-template-columns:64px minmax(0,1fr) minmax(430px,536px);gap:20px;align-items:center;padding:20px 24px;border:1px solid #dfe5f1;border-radius:14px;background:rgba(255,255,255,.97);box-shadow:0 18px 54px rgba(15,23,42,.14);color:#080d2a}
809
+ .nommos-cmp-logo{display:flex;align-items:center;gap:10px;font-weight:800;letter-spacing:2px;min-width:0}.nommos-cmp-logo img{max-width:112px;max-height:34px;object-fit:contain}.nommos-cmp-logo svg{width:30px;height:30px;color:${primary}}
810
+ .nommos-cmp-banner .nommos-cmp-logo{width:52px;height:52px;justify-content:center;border-radius:12px;background:color-mix(in srgb, ${primary} 12%, white)}.nommos-cmp-banner .nommos-cmp-logo strong{display:none}.nommos-cmp-banner .nommos-cmp-logo svg{width:32px;height:32px}
811
+ .nommos-cmp-banner h2,.nommos-cmp-panel h2,.nommos-cmp-reconsent h2{margin:0 0 8px;font-size:22px;line-height:1.15;color:#080d2a}.nommos-cmp-banner p,.nommos-cmp-panel p,.nommos-cmp-reconsent p{margin:0;color:#364361;font-size:15px;line-height:1.45}
812
+ .nommos-cmp-banner .nommos-cmp-chip-row{display:none}.nommos-cmp-chip-row{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-top:18px}.nommos-cmp-chip-row-center{margin:18px 0}.nommos-cmp-chip{min-height:58px;display:grid;grid-template-columns:24px 1fr 20px;gap:8px;align-items:center;padding:12px;border-radius:10px;background:#f4f6fb;color:#0a102d}.nommos-cmp-chip svg{width:22px;height:22px}.nommos-cmp-chip strong{font-size:13px}.nommos-cmp-chip em{grid-column:2;font-style:normal;color:#14a34a;font-size:12px}
813
+ .nommos-cmp-banner-actions{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;align-items:center}.nommos-cmp-btn{height:48px;border-radius:8px;border:1px solid #cfd7ea;display:inline-flex;align-items:center;justify-content:center;gap:9px;font-size:15px;font-weight:800;cursor:pointer;background:#fff;color:${primary};white-space:nowrap}.nommos-cmp-btn svg{width:18px;height:18px}.nommos-cmp-primary{border-color:${primary};background:linear-gradient(135deg,${primary},${secondary});color:#fff;box-shadow:0 8px 20px color-mix(in srgb, ${primary} 24%, transparent)}
814
+ .nommos-cmp-links{grid-column:1 / -1;display:flex;gap:18px;justify-content:center;margin-top:14px}.nommos-cmp-links a{color:${primary};font-size:13px;font-weight:700;text-decoration:underline}.nommos-cmp-links span{width:1px;background:#98a1b8}.nommos-cmp-powered{grid-column:1 / -1;text-align:right;color:#7c849a;font-size:13px}.nommos-cmp-powered strong{color:#179bd7}
815
+ .nommos-cmp-floating{position:fixed;right:24px;bottom:22px;z-index:2147483001;display:flex;align-items:center;gap:16px;min-height:68px;padding:10px 24px 10px 10px;border:0;border-radius:999px;background:#fff;color:#080d2a;box-shadow:0 14px 44px rgba(15,23,42,.18);font-weight:800;cursor:pointer}.nommos-cmp-floating svg{width:48px;height:48px;padding:10px;border-radius:999px;background:linear-gradient(135deg,${primary},${secondary});color:#fff}
816
+ .nommos-cmp-scrim,.nommos-cmp-reconsent-scrim{position:fixed;inset:0;z-index:2147482998;background:rgba(18,24,43,.22);backdrop-filter:blur(2px)}.nommos-cmp-panel{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);z-index:2147483000;width:min(980px,calc(100vw - 48px));max-height:calc(100vh - 52px);display:flex;flex-direction:column;padding:28px 28px 22px;border:1px solid #dfe5f1;border-radius:14px;background:#fff;box-shadow:0 24px 78px rgba(15,23,42,.18);color:#080d2a;overflow:auto}.nommos-cmp-close{position:absolute;right:22px;top:22px;width:34px;height:34px;border:0;border-radius:8px;background:#fff;color:#64708d;cursor:pointer}.nommos-cmp-close svg{width:22px;height:22px}
817
+ .nommos-cmp-panel-head{display:flex;gap:18px;margin:8px 56px 24px 0}.nommos-cmp-shield{width:58px;height:58px;display:grid;place-items:center;border:0;border-radius:14px;background:color-mix(in srgb, ${primary} 12%, white);color:${primary};flex:0 0 auto}.nommos-cmp-shield svg{width:32px;height:32px}.nommos-cmp-pref-list{border:1px solid #dfe5f1;border-radius:8px;overflow:hidden}.nommos-cmp-pref-row{min-height:82px;display:grid;grid-template-columns:48px minmax(0,1fr) auto;gap:16px;align-items:center;padding:14px 16px 14px 18px;border-bottom:1px solid #dfe5f1}.nommos-cmp-pref-row:last-child{border-bottom:0}.nommos-cmp-pref-row>span:first-child{width:38px;height:38px;display:grid;place-items:center;border-radius:10px;background:color-mix(in srgb, ${primary} 10%, white);color:${primary}}.nommos-cmp-pref-row>span svg{width:24px;height:24px}.nommos-cmp-pref-row h3{margin:0 0 6px;font-size:17px;display:flex;gap:10px;align-items:center}.nommos-cmp-pref-row h3 span{padding:5px 10px;border-radius:999px;background:#eef0f5;color:#52607a;font-size:12px;font-weight:700}.nommos-cmp-pref-row p{font-size:14px;line-height:1.35}.nommos-cmp-pref-row a{display:none}.nommos-cmp-lock{width:36px;height:36px;display:grid;place-items:center;border-radius:10px;background:#f1f3f8;color:#69738d}
818
+ .nommos-cmp-toggle{position:relative;width:48px;height:28px}.nommos-cmp-toggle input{opacity:0;width:0;height:0}.nommos-cmp-toggle span{position:absolute;inset:0;border-radius:999px;background:#c7ccd8;transition:.2s}.nommos-cmp-toggle span:before{content:"";position:absolute;width:22px;height:22px;left:3px;top:3px;border-radius:999px;background:#fff;transition:.2s;box-shadow:0 2px 8px rgba(0,0,0,.18)}.nommos-cmp-toggle input:checked+span{background:linear-gradient(135deg,${primary},${secondary})}.nommos-cmp-toggle input:checked+span:before{transform:translateX(20px)}.nommos-cmp-toggle input:disabled+span{opacity:.55}
819
+ .nommos-cmp-panel-footer{margin-top:18px;display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;padding-top:0}.nommos-cmp-panel-meta{grid-column:1/-1;display:flex;justify-content:space-between;align-items:center;margin-top:8px}.nommos-cmp-panel-meta .nommos-cmp-links{margin:0;grid-column:auto}.nommos-cmp-panel-meta .nommos-cmp-powered{grid-column:auto}
820
+ .nommos-cmp-toast{position:fixed;right:34px;top:34px;z-index:2147483002;min-width:360px;display:grid;grid-template-columns:48px 1fr 28px;gap:12px;align-items:center;padding:18px;border:1px solid #b7e2c5;border-bottom:4px solid #16a34a;border-radius:10px;background:#fff;box-shadow:0 18px 48px rgba(15,23,42,.16);color:#102018}.nommos-cmp-toast-error{border-color:#fecaca;border-bottom-color:#dc2626}.nommos-cmp-toast-icon{display:grid;place-items:center;width:42px;height:42px;border-radius:999px;background:#13b84a;color:#fff}.nommos-cmp-toast-error .nommos-cmp-toast-icon{background:#dc2626}.nommos-cmp-toast svg{width:22px;height:22px}.nommos-cmp-toast strong,.nommos-cmp-toast small{display:block}.nommos-cmp-toast small{color:#647085;margin-top:4px}.nommos-cmp-toast button{border:0;background:transparent;color:#647085;cursor:pointer}
821
+ .nommos-cmp-summary{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);z-index:2147482999;display:flex;align-items:center;gap:18px;min-height:54px;max-width:calc(100vw - 220px);padding:10px 20px;border:1px solid #e7eaf2;border-radius:14px;background:#fff;box-shadow:0 14px 40px rgba(15,23,42,.16);color:#0c122e;font-size:13px}.nommos-cmp-summary span{display:flex;align-items:center;gap:7px;border-left:1px solid #e4e8f0;padding-left:14px;white-space:nowrap}.nommos-cmp-summary svg{width:18px;height:18px}.nommos-cmp-summary em{font-style:normal;color:#16a34a;font-weight:700}.nommos-cmp-summary button{border:0;background:transparent;color:${primary};font-weight:800;cursor:pointer}
822
+ .nommos-cmp-reconsent{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);z-index:2147483000;width:min(590px,calc(100vw - 32px));padding:40px;border-radius:22px;background:#fff;color:#080d2a;text-align:center;box-shadow:0 28px 90px rgba(15,23,42,.28)}.nommos-cmp-reconsent-icon{width:80px;height:80px;margin:0 auto 20px;display:grid;place-items:center;border-radius:999px;background:color-mix(in srgb, ${primary} 12%, white);color:${primary}}.nommos-cmp-reconsent-icon svg{width:42px;height:42px}.nommos-cmp-version-row{display:flex;justify-content:center;gap:18px;margin:20px 0;color:#68738c;font-size:13px}.nommos-cmp-version-row span:first-child{padding:7px 12px;border-radius:999px;background:color-mix(in srgb, ${primary} 10%, white);color:${primary};font-weight:800}.nommos-cmp-change{text-align:left;margin:18px 0}.nommos-cmp-reconsent-actions{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-top:18px}
823
+ @media (max-width: 860px){.nommos-cmp-banner{left:12px;right:12px;bottom:12px;grid-template-columns:44px 1fr;padding:16px}.nommos-cmp-banner-actions{grid-column:1/-1;grid-template-columns:1fr;gap:8px}.nommos-cmp-banner .nommos-cmp-logo{width:42px;height:42px}.nommos-cmp-banner h2{font-size:18px}.nommos-cmp-banner p{font-size:13px}.nommos-cmp-chip-row,.nommos-cmp-panel-footer,.nommos-cmp-reconsent-actions{grid-template-columns:1fr}.nommos-cmp-panel{left:0;right:0;top:auto;bottom:0;transform:none;width:100%;max-height:88vh;border-radius:18px 18px 0 0;padding:22px}.nommos-cmp-panel-head{margin-right:48px}.nommos-cmp-pref-row{min-height:auto;padding:14px;grid-template-columns:40px 1fr auto}.nommos-cmp-summary{left:12px;right:12px;bottom:96px;transform:none;max-width:none;overflow:auto}.nommos-cmp-floating{right:14px;bottom:14px}.nommos-cmp-toast{left:12px;right:12px;top:18px;min-width:0}}
824
+ `;
825
+ document.head.appendChild(style);
826
+ }
827
+ }
828
+ //# sourceMappingURL=consent-manager.js.map