@ecomconsult/consentkit 0.3.2

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/src/ck-ui.js ADDED
@@ -0,0 +1,1421 @@
1
+ /* ConsentKit UI layer. Shadow DOM banner, preferences panel, floating button.
2
+ Talks to the core only through the public API and ck:* events. No imports, no external assets.
3
+
4
+ Copyright (c) 2026 E-COM CONSULT PLUS. MIT License — see LICENSE. */
5
+ (function () {
6
+ 'use strict';
7
+
8
+ var OPT_IN = ['functional', 'analytics', 'marketing'];
9
+ var ALL_CATS = ['necessary'].concat(OPT_IN);
10
+
11
+ /* ---------------------------------------------------------------- i18n */
12
+
13
+ var DICT = {
14
+ en: {
15
+ bannerTitle: 'We use cookies',
16
+ bannerText: 'Necessary cookies keep the site working. Everything else — analytics, marketing, extra features — runs only if you allow it. You can change your mind at any time.',
17
+ more: 'Learn more',
18
+ acceptAll: 'Accept all',
19
+ rejectAll: 'Reject all',
20
+ customize: 'Customize',
21
+ bannerLabel: 'Cookie consent',
22
+ panelTitle: 'Cookie settings',
23
+ panelIntro: 'Choose which cookies you allow. Nothing optional is on until you turn it on.',
24
+ save: 'Save choices',
25
+ close: 'Close',
26
+ alwaysOn: 'always on',
27
+ cookiesIn: 'Cookies in this group',
28
+ noCookies: 'No cookies declared for this group.',
29
+ colName: 'Name',
30
+ colVendor: 'Provider',
31
+ colPurpose: 'Purpose',
32
+ colExpiry: 'Expires',
33
+ floating: 'Cookie settings',
34
+ poweredBy: 'Powered by ConsentKit',
35
+ cat: {
36
+ necessary: {
37
+ title: 'Necessary',
38
+ desc: 'Needed for the site to work — signing in, security, remembering your consent. They cannot be turned off.'
39
+ },
40
+ functional: {
41
+ title: 'Functional',
42
+ desc: 'Remember your preferences, such as language or chat, so you do not set them up again.'
43
+ },
44
+ analytics: {
45
+ title: 'Analytics',
46
+ desc: 'Help us see which pages people use, so we can fix what is confusing. Numbers only, no names.'
47
+ },
48
+ marketing: {
49
+ title: 'Marketing',
50
+ desc: 'Let us show you ads on other sites and measure whether they were any use.'
51
+ }
52
+ }
53
+ },
54
+ ru: {
55
+ bannerTitle: 'Мы используем cookie',
56
+ bannerText: 'Необходимые cookie нужны, чтобы сайт работал. Всё остальное — аналитика, маркетинг, дополнительные удобства — включается только с вашего согласия. Решение можно изменить в любой момент.',
57
+ more: 'Подробнее',
58
+ acceptAll: 'Принять всё',
59
+ rejectAll: 'Отклонить всё',
60
+ customize: 'Настроить',
61
+ bannerLabel: 'Согласие на cookie',
62
+ panelTitle: 'Настройки cookie',
63
+ panelIntro: 'Выберите, какие cookie вы разрешаете. Ничего необязательного не включено, пока вы сами это не сделаете.',
64
+ save: 'Сохранить выбор',
65
+ close: 'Закрыть',
66
+ alwaysOn: 'всегда активны',
67
+ cookiesIn: 'Cookie в этой группе',
68
+ noCookies: 'Для этой группы cookie не заявлены.',
69
+ colName: 'Имя',
70
+ colVendor: 'Поставщик',
71
+ colPurpose: 'Цель',
72
+ colExpiry: 'Срок',
73
+ floating: 'Настройки cookie',
74
+ poweredBy: 'Работает на ConsentKit',
75
+ cat: {
76
+ necessary: {
77
+ title: 'Необходимые',
78
+ desc: 'Без них сайт не работает: вход, безопасность, память о вашем выборе. Отключить нельзя.'
79
+ },
80
+ functional: {
81
+ title: 'Функциональные',
82
+ desc: 'Запоминают ваши настройки — например язык или чат, — чтобы вы не задавали их заново.'
83
+ },
84
+ analytics: {
85
+ title: 'Аналитика',
86
+ desc: 'Показывают нам, какими страницами вы пользуетесь, чтобы мы исправили неудобное. Только цифры, без имён.'
87
+ },
88
+ marketing: {
89
+ title: 'Маркетинг',
90
+ desc: 'Позволяют показывать вам рекламу на других сайтах и понимать, была ли от неё польза.'
91
+ }
92
+ }
93
+ }
94
+ };
95
+
96
+ // Keys an external locale may supply. Kept in sync with DICT.en by shape.
97
+ // A key listed here but missing from an external locale falls back to DICT.en
98
+ // in buildStrings() — that is what keeps the 32 locales in ck-locales.js whole
99
+ // when a new key like poweredBy is added here and not (yet) translated there.
100
+ // Adding a key to DICT without adding it here would leave T.<key> undefined
101
+ // for every external locale and render the literal string "undefined".
102
+ var STR_KEYS = [
103
+ 'bannerTitle', 'bannerText', 'more', 'acceptAll', 'rejectAll', 'customize',
104
+ 'bannerLabel', 'panelTitle', 'panelIntro', 'save', 'close', 'alwaysOn',
105
+ 'cookiesIn', 'noCookies', 'colName', 'colVendor', 'colPurpose', 'colExpiry', 'floating',
106
+ 'poweredBy'
107
+ ];
108
+
109
+ // builtin(en,ru) <- window.__ckLocales, read at render time so the locales
110
+ // file may load in any order relative to this one.
111
+ function localeTable() {
112
+ var table = {};
113
+ var k;
114
+ for (k in DICT) {
115
+ if (Object.prototype.hasOwnProperty.call(DICT, k)) table[k.toLowerCase()] = DICT[k];
116
+ }
117
+ var ext = (typeof window !== 'undefined') && window.__ckLocales;
118
+ if (!ext || typeof ext !== 'object') return table;
119
+ for (k in ext) {
120
+ if (!Object.prototype.hasOwnProperty.call(ext, k)) continue;
121
+ var v = ext[k];
122
+ if (v && typeof v === 'object') table[String(k).toLowerCase()] = v;
123
+ }
124
+ return table;
125
+ }
126
+
127
+ // exact lowercase match -> first two letters (pt-BR -> pt) -> en
128
+ function resolveLang(cfgLang, table) {
129
+ var raw = cfgLang;
130
+ if (!raw || raw === 'auto') {
131
+ raw = (typeof navigator !== 'undefined' && (navigator.language || navigator.userLanguage)) || 'en';
132
+ }
133
+ var code = String(raw).toLowerCase();
134
+ if (table[code]) return code;
135
+ var short = code.slice(0, 2);
136
+ if (table[short]) return short;
137
+ return 'en';
138
+ }
139
+
140
+ // Deep two-level fill from en: a partial locale must never yield undefined,
141
+ // which would render the literal string "undefined".
142
+ function buildStrings(lang, table) {
143
+ var src = table[lang] || {};
144
+ var base = DICT.en;
145
+ var out = {};
146
+ var i, c;
147
+ for (i = 0; i < STR_KEYS.length; i++) {
148
+ var k = STR_KEYS[i];
149
+ out[k] = (typeof src[k] === 'string' && src[k]) ? src[k] : base[k];
150
+ }
151
+ out.cat = {};
152
+ var sc = (src.cat && typeof src.cat === 'object') ? src.cat : {};
153
+ for (i = 0; i < ALL_CATS.length; i++) {
154
+ c = ALL_CATS[i];
155
+ var e = (sc[c] && typeof sc[c] === 'object') ? sc[c] : {};
156
+ out.cat[c] = {
157
+ title: (typeof e.title === 'string' && e.title) ? e.title : base.cat[c].title,
158
+ desc: (typeof e.desc === 'string' && e.desc) ? e.desc : base.cat[c].desc
159
+ };
160
+ }
161
+ return out;
162
+ }
163
+
164
+ /* --------------------------------------------------------------- styles */
165
+
166
+ var CSS = [
167
+ ':host{all:initial}',
168
+ '*,*::before,*::after{box-sizing:border-box}',
169
+ /* Palette tokens live in a second, generated stylesheet (see buildThemeCss).
170
+ They must NOT be inline host styles: an inline value outbeats every :host
171
+ rule, which would make the dark media query and the forced-mode class dead. */
172
+ ':host{',
173
+ 'font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;',
174
+ 'font-size:15px;line-height:1.5;color:var(--ck-ink);',
175
+ '-webkit-font-smoothing:antialiased}',
176
+
177
+ '.ck-hidden{display:none !important}',
178
+
179
+ 'button{font:inherit;color:inherit;margin:0;cursor:pointer}',
180
+ 'a{color:var(--ck-accent)}',
181
+ ':focus-visible{outline:2px solid var(--ck-accent);outline-offset:2px;border-radius:4px}',
182
+
183
+ /* ---- banner ---- */
184
+ '.ck-scrim{position:fixed;inset:0;background:rgba(16,20,30,.28);z-index:2147483000;pointer-events:none}',
185
+ '.ck-banner{position:fixed;z-index:2147483001;background:var(--ck-bg);color:var(--ck-ink);',
186
+ 'border:1px solid var(--ck-line);border-radius:var(--ck-radius);pointer-events:auto}',
187
+ '.ck-banner--bar{left:16px;right:16px;padding:18px 20px;',
188
+ 'display:flex;gap:20px;align-items:center;flex-wrap:wrap}',
189
+ '.ck-banner--bar.ck-pos-bottom{bottom:16px}',
190
+ '.ck-banner--bar.ck-pos-top{top:16px}',
191
+ '.ck-banner--modal{top:50%;left:50%;transform:translate(-50%,-50%);',
192
+ 'width:min(560px,calc(100vw - 32px));max-height:calc(100vh - 32px);overflow:auto;padding:24px}',
193
+
194
+ /* box: compact card, corner-anchored, no scrim */
195
+ '.ck-banner--box{width:min(360px,calc(100vw - 32px));max-height:calc(100vh - 32px);',
196
+ 'overflow:auto;padding:20px;display:block}',
197
+ '.ck-banner--box.ck-pos-bottom-right{bottom:16px;right:16px}',
198
+ '.ck-banner--box.ck-pos-bottom-left{bottom:16px;left:16px}',
199
+ /* Vertical layouts: the copy ends with the "learn more" link, so the gap
200
+ below it has to clear a text baseline, not just a block edge — 16px
201
+ reads as attached to the buttons. */
202
+ '.ck-banner.ck-banner--box p,.ck-banner.ck-banner--modal p{margin-bottom:22px}',
203
+ /* both filled buttons share one equal row; outline spans the width below */
204
+ '.ck-banner--box .ck-actions{display:grid;grid-template-columns:1fr 1fr;gap:8px}',
205
+ '.ck-banner--box .ck-btn{min-width:0;width:100%}',
206
+ '.ck-banner--box .ck-btn--outline{grid-column:1 / -1}',
207
+ '.ck-banner__body{flex:1 1 320px;min-width:0}',
208
+ '.ck-banner h2{margin:0 0 6px;font-size:17px;font-weight:600;letter-spacing:-.01em}',
209
+ '.ck-banner p{margin:0;color:var(--ck-muted);font-size:14px}',
210
+ '.ck-banner--modal p{margin-bottom:20px}',
211
+ '.ck-banner__more{white-space:nowrap}',
212
+
213
+ /* ---- equal-weight action row ---- */
214
+ '.ck-actions{display:flex;gap:10px;flex-wrap:wrap;flex:0 1 auto}',
215
+ '.ck-banner--modal .ck-actions{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr))}',
216
+ '.ck-btn{display:inline-flex;align-items:center;justify-content:center;text-align:center;',
217
+ 'min-width:150px;min-height:44px;padding:11px 18px;font-size:14px;font-weight:600;line-height:1.2;',
218
+ 'border-radius:var(--ck-radius);border:1px solid transparent;background:transparent;flex:1 1 auto}',
219
+ '.ck-btn--filled{background:var(--ck-accent);border-color:var(--ck-accent);color:var(--ck-on-accent)}',
220
+ '.ck-btn--outline{background:transparent;border-color:var(--ck-accent);color:var(--ck-accent)}',
221
+ '.ck-btn--ghost{min-width:0;border-color:var(--ck-line);color:var(--ck-ink);font-weight:500}',
222
+
223
+ /* ---- panel ---- */
224
+ '.ck-panel-scrim{position:fixed;inset:0;background:rgba(16,20,30,.34);z-index:2147483002;pointer-events:none}',
225
+ '.ck-panel{position:fixed;z-index:2147483003;top:50%;left:50%;transform:translate(-50%,-50%);',
226
+ 'width:min(620px,calc(100vw - 32px));max-height:calc(100vh - 48px);',
227
+ 'display:flex;flex-direction:column;background:var(--ck-bg);color:var(--ck-ink);',
228
+ 'border:1px solid var(--ck-line);border-radius:var(--ck-radius);overflow:hidden}',
229
+ '.ck-panel__head{display:flex;align-items:flex-start;gap:16px;padding:22px 24px 14px;',
230
+ 'border-bottom:1px solid var(--ck-line)}',
231
+ /* min-width:0 so a wide logo shrinks rather than shoving the close button
232
+ off. Scoped to :has(.ck-brand) — applying it unconditionally changes the
233
+ header block's flex sizing (480px -> 518px) on unbranded panels too, which
234
+ would break byte-for-byte backward compatibility. Browsers without :has()
235
+ simply keep today's sizing; the logo is width-capped at 160px regardless,
236
+ so the close button still has room. */
237
+ '.ck-panel__head>div:first-child:has(.ck-brand){flex:1 1 auto;min-width:0}',
238
+ '.ck-panel__head h2{margin:0 0 4px;font-size:18px;font-weight:600;letter-spacing:-.01em}',
239
+ '.ck-panel__head p{margin:0;font-size:14px;color:var(--ck-muted)}',
240
+ '.ck-x{flex:none;width:36px;height:36px;border-radius:var(--ck-radius);border:1px solid var(--ck-line);',
241
+ 'background:transparent;display:inline-flex;align-items:center;justify-content:center;color:var(--ck-muted)}',
242
+ '.ck-panel__body{overflow:auto;padding:6px 24px 10px;-webkit-overflow-scrolling:touch}',
243
+ '.ck-panel__foot{display:flex;gap:10px;flex-wrap:wrap;padding:16px 24px;',
244
+ 'border-top:1px solid var(--ck-line);background:var(--ck-soft)}',
245
+ '.ck-panel__foot .ck-btn{flex:1 1 150px}',
246
+
247
+ /* ---- category row ---- */
248
+ '.ck-cat{padding:16px 0;border-bottom:1px solid var(--ck-line)}',
249
+ '.ck-cat:last-child{border-bottom:0}',
250
+ '.ck-cat__top{display:flex;gap:16px;align-items:flex-start}',
251
+ '.ck-cat__txt{flex:1 1 auto;min-width:0}',
252
+ '.ck-cat__name{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:15px;font-weight:600}',
253
+ '.ck-cat__badge{font-size:12px;font-weight:500;color:var(--ck-muted);',
254
+ 'border:1px solid var(--ck-line);border-radius:999px;padding:1px 8px}',
255
+ '.ck-cat__desc{margin:4px 0 0;font-size:13.5px;color:var(--ck-muted)}',
256
+
257
+ /* ---- switch ---- */
258
+ '.ck-switch{flex:none;width:46px;height:27px;padding:0;border-radius:999px;',
259
+ 'border:1px solid var(--ck-line);background:var(--ck-soft);position:relative}',
260
+ '.ck-switch::after{content:"";position:absolute;top:2px;left:2px;width:21px;height:21px;',
261
+ 'border-radius:50%;background:var(--ck-bg);border:1px solid var(--ck-line)}',
262
+ '.ck-switch[aria-checked="true"]{background:var(--ck-accent);border-color:var(--ck-accent)}',
263
+ '.ck-switch[aria-checked="true"]::after{left:auto;right:2px;border-color:transparent}',
264
+ '.ck-switch[disabled]{cursor:not-allowed;opacity:.55}',
265
+
266
+ /* ---- cookie table ---- */
267
+ '.ck-det{margin-top:12px}',
268
+ '.ck-det>summary{cursor:pointer;font-size:13px;color:var(--ck-accent);',
269
+ 'list-style:none;display:inline-flex;align-items:center;gap:6px;padding:2px 0}',
270
+ '.ck-det>summary::-webkit-details-marker{display:none}',
271
+ '.ck-det>summary::before{content:"";width:0;height:0;border:4px solid transparent;',
272
+ 'border-left-color:currentColor;border-right:0}',
273
+ '.ck-det[open]>summary::before{transform:rotate(90deg)}',
274
+ '.ck-tablewrap{margin-top:8px;overflow-x:auto;border:1px solid var(--ck-line);border-radius:var(--ck-radius)}',
275
+ 'table{border-collapse:collapse;width:100%;font-size:13px;min-width:420px}',
276
+ 'th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--ck-line);vertical-align:top}',
277
+ 'thead th{background:var(--ck-soft);font-weight:600;font-size:12px;color:var(--ck-muted);white-space:nowrap}',
278
+ 'tbody tr:last-child td{border-bottom:0}',
279
+ 'td.ck-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}',
280
+ '.ck-empty{margin:8px 0 0;font-size:13px;color:var(--ck-muted)}',
281
+
282
+ /* ---- branding: logo + powered-by ----
283
+ The logo sits inline with the title in a flex row. That was chosen over a
284
+ separate band above the heading because .ck-banner--bar is a single
285
+ vertically-centred flex row: a stacked logo adds a height band there and
286
+ nowhere else, so bar/box/modal would drift apart. Inline keeps one rule
287
+ for all three layouts and leaves existing margins untouched — .ck-brand
288
+ carries the whole gap, h2 keeps its own margin. */
289
+ '.ck-brand{display:flex;align-items:center;gap:10px;margin:0 0 8px}',
290
+ /* Attribution foot of the banner: logo + credit on one muted line below
291
+ the buttons. flex-basis 100% keeps it on its own row in the bar layout,
292
+ where the actions sit beside the text.
293
+
294
+ The mark is desaturated here rather than shipped as a second grey asset:
295
+ an agency logo in full brand colour reads as a second call to action
296
+ competing with the consent buttons. grayscale() flattens the hue and the
297
+ opacity lifts it off pure black, so it sits at signature weight in both
298
+ themes without the integrator preparing anything. */
299
+ '.ck-foot{display:flex;align-items:center;gap:8px;flex-wrap:wrap;',
300
+ 'margin:14px 0 0}',
301
+ '.ck-foot .ck-brand__logo,.ck-foot .ck-brand__logo svg{',
302
+ 'filter:grayscale(1);opacity:.55}',
303
+ '.ck-foot .ck-brand__link:hover .ck-brand__logo,',
304
+ '.ck-foot .ck-brand__link:focus-visible .ck-brand__logo{opacity:.8}',
305
+ '.ck-foot .ck-brand{margin:0}',
306
+ /* Beats the flex:1 1 100% the standalone .ck-powered carries (it needs a
307
+ full row of its own when there is no logo beside it). */
308
+ '.ck-foot p.ck-powered,.ck-banner .ck-foot p.ck-powered{margin:0;flex:0 1 auto}',
309
+ /* In the panel the foot shares a flex row with the action buttons, so it
310
+ claims a row of its own below them. */
311
+ '.ck-panel__foot .ck-foot{flex:1 1 100%;margin:2px 0 0}',
312
+ '.ck-brand__logo{display:block;width:auto;max-width:160px;height:var(--ck-logo-h,24px);',
313
+ 'flex:none;object-fit:contain}',
314
+ '.ck-brand__logo svg{display:block;width:auto;height:100%;max-width:160px}',
315
+ '.ck-brand a.ck-brand__link{display:inline-flex;align-items:center;text-decoration:none;flex:none}',
316
+ /* Dark-variant swap is CSS-driven, mirroring buildThemeCss()'s cascade
317
+ exactly (same three selectors, same :not(.ck-mode-light) guard). Reading
318
+ the theme in JS would desync in auto mode and would not follow a live
319
+ system theme flip. */
320
+ '.ck-brand__dark{display:none}',
321
+ '.ck-brand__has-dark .ck-brand__light{display:block}',
322
+
323
+ /* ---- powered-by ----
324
+ Deliberately quiet: muted colour, 12px, normal weight, and it comes after
325
+ the action row in DOM order. It must not compete with the consent buttons. */
326
+ /* .ck-banner p sets font-size:14px at equal specificity and appears later in
327
+ this sheet, so it would win over a bare .ck-powered. Qualifying the
328
+ selector keeps the attribution smaller than the button text (14px) without
329
+ reaching for !important. */
330
+ '.ck-powered,.ck-banner p.ck-powered{margin:12px 0 0;font-size:12px;line-height:1.4;',
331
+ 'color:var(--ck-muted);flex:1 1 100%;font-weight:400}',
332
+ '.ck-powered a{color:var(--ck-muted);text-decoration:underline}',
333
+ '.ck-panel__foot .ck-powered{margin:0;align-self:center}',
334
+
335
+ /* ---- floating button ---- */
336
+ '.ck-fab{position:fixed;left:16px;bottom:16px;z-index:2147482999;width:48px;height:48px;',
337
+ 'border-radius:50%;border:1px solid var(--ck-line);background:var(--ck-bg);color:var(--ck-accent);',
338
+ 'display:inline-flex;align-items:center;justify-content:center;padding:0}',
339
+ '.ck-fab svg{width:24px;height:24px;display:block}',
340
+
341
+ '@media (max-width:560px){',
342
+ '.ck-banner--bar{left:8px;right:8px;bottom:8px;padding:16px}',
343
+ '.ck-actions{width:100%}.ck-btn{min-width:0;flex:1 1 100%}',
344
+ '.ck-banner.ck-banner--bar p{margin-bottom:22px}',
345
+ /* Narrow bar stacks into a column, so the foot — which lives at the end of
346
+ the text block for the wide side-by-side layout — would sit between the
347
+ question and the buttons answering it. Lift it out of the text block and
348
+ order it last. */
349
+ '.ck-banner--bar{flex-direction:column;align-items:stretch}',
350
+ '.ck-banner--bar .ck-banner__body{display:contents}',
351
+ '.ck-banner--bar .ck-banner__body>*{order:1}',
352
+ '.ck-banner--bar .ck-actions{order:2}',
353
+ '.ck-banner--bar .ck-foot{order:3;margin-top:14px}}',
354
+
355
+ '@media (prefers-reduced-motion: no-preference){',
356
+ '.ck-btn,.ck-x,.ck-fab,.ck-switch,.ck-switch::after{transition:background-color .16s ease,border-color .16s ease,color .16s ease,left .16s ease,right .16s ease}}'
357
+ ].join('\n');
358
+
359
+ var COOKIE_ICON =
360
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" ' +
361
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' +
362
+ '<path d="M21 12a9 9 0 1 1-9-9 3.4 3.4 0 0 0 4.2 4.2A3.4 3.4 0 0 0 21 12Z"/>' +
363
+ '<circle cx="9" cy="10" r="1"/><circle cx="14.5" cy="15" r="1"/><circle cx="8.5" cy="15.5" r="1"/>' +
364
+ '</svg>';
365
+
366
+ /* --------------------------------------------------------------- helpers */
367
+
368
+ function el(tag, cls, text) {
369
+ var n = document.createElement(tag);
370
+ if (cls) n.className = cls;
371
+ if (text != null) n.textContent = text;
372
+ return n;
373
+ }
374
+
375
+ function api() {
376
+ return (typeof window !== 'undefined' && window.ConsentKit) || null;
377
+ }
378
+
379
+ /* -------------------------------------------------------------- branding */
380
+
381
+ /* Restraint is the design rule here, not a matter of taste.
382
+
383
+ A consent banner is shown to every visitor of the site that installs it,
384
+ and it asks them a legal question. An agency logo, agency colours and an
385
+ attribution line all at once make it read as the agency's dialogue rather
386
+ than the site's own — visitors trust it less, and the banner competes with
387
+ the page it sits on.
388
+
389
+ So: everything in `branding` is off unless asked for, and the recommended
390
+ shape is one small logo (16–20px) OR one attribution line — with
391
+ theme.accent left matching the HOST SITE, never the agency's colour.
392
+ Nothing here may outweigh the consent buttons. */
393
+
394
+ /* SVG sanitiser.
395
+
396
+ branding.logo may be a raw SVG string coming from a server-rendered config
397
+ or a WordPress admin field. That is untrusted input, so it never reaches
398
+ innerHTML: `<svg onload=...>` executes on insertion, and so do SMIL
399
+ `<animate onbegin=...>` and `<foreignObject><img onerror=...>`.
400
+
401
+ Approach chosen: parse inert, then REBUILD rather than strip-and-adopt.
402
+ DOMParser with 'image/svg+xml' yields a detached, non-live document where
403
+ nothing runs. We then walk that tree and construct a brand-new tree with
404
+ createElementNS, copying across only allowlisted tags and attributes.
405
+
406
+ Rebuilding is what makes this safe rather than merely careful. The rejected
407
+ alternative — importNode/appendChild the parsed tree after deleting bad
408
+ attributes — arms every inline handler at the moment of adoption, so a
409
+ single missed attribute name is live code. Here an attribute we do not
410
+ recognise is simply never written, so the failure mode is a missing
411
+ decoration, not script execution. Allowlists (closed) beat blocklists
412
+ (open-ended) for the same reason.
413
+
414
+ Deliberately excluded, each for a concrete reason:
415
+ script - obvious
416
+ foreignObject - escape hatch back into full HTML
417
+ use, image - can reference/fetch external documents
418
+ a - javascript: navigation inside the logo
419
+ style - CSS escapes, and it would leak out of the
420
+ logo into our own shadow-root styling
421
+ animate/set/animateTransform - SMIL takes an attributeName and can drive
422
+ arbitrary attributes, plus on* timing events
423
+
424
+ Anything unexpected bails to null (no logo) rather than partially rendering. */
425
+
426
+ var SVG_NS = 'http://www.w3.org/2000/svg';
427
+
428
+ var SVG_TAGS = {
429
+ svg: 1, g: 1, path: 1, circle: 1, ellipse: 1, rect: 1, line: 1,
430
+ polyline: 1, polygon: 1, defs: 1, title: 1, desc: 1,
431
+ lineargradient: 1, radialgradient: 1, stop: 1, clippath: 1, mask: 1
432
+ };
433
+
434
+ // Presentation/geometry only. No href/xlink:href in any form, no on* events.
435
+ var SVG_ATTRS = {
436
+ viewbox: 'viewBox', preserveaspectratio: 'preserveAspectRatio',
437
+ xmlns: 'xmlns', version: 'version',
438
+ d: 'd', fill: 'fill', 'fill-rule': 'fill-rule', 'fill-opacity': 'fill-opacity',
439
+ 'clip-rule': 'clip-rule', 'clip-path': 'clip-path', mask: 'mask',
440
+ stroke: 'stroke', 'stroke-width': 'stroke-width', 'stroke-linecap': 'stroke-linecap',
441
+ 'stroke-linejoin': 'stroke-linejoin', 'stroke-dasharray': 'stroke-dasharray',
442
+ 'stroke-dashoffset': 'stroke-dashoffset', 'stroke-opacity': 'stroke-opacity',
443
+ 'stroke-miterlimit': 'stroke-miterlimit',
444
+ opacity: 'opacity', transform: 'transform',
445
+ x: 'x', y: 'y', x1: 'x1', y1: 'y1', x2: 'x2', y2: 'y2',
446
+ cx: 'cx', cy: 'cy', r: 'r', rx: 'rx', ry: 'ry',
447
+ width: 'width', height: 'height', points: 'points',
448
+ offset: 'offset', 'stop-color': 'stop-color', 'stop-opacity': 'stop-opacity',
449
+ gradientunits: 'gradientUnits', gradienttransform: 'gradientTransform',
450
+ spreadmethod: 'spreadMethod', clippathunits: 'clipPathUnits',
451
+ maskunits: 'maskUnits', maskcontentunits: 'maskContentUnits',
452
+ id: 'id', 'class': 'class'
453
+ };
454
+
455
+ /* Every sanitised logo gets a unique id namespace.
456
+
457
+ buildBrandLogo() runs twice per mount (banner + panel head), and doubles
458
+ again when logoDark is set — so one shadow root can hold four copies of the
459
+ same asset. A gradient/clipPath/mask id like "g" would then appear four
460
+ times, and url(#g) resolves to the FIRST match in the tree: the dark logo
461
+ would silently paint with the light logo's gradient stops. Prefixing every
462
+ id per instance, and rewriting the url(#…) references in the same pass,
463
+ keeps each copy self-contained. */
464
+ var svgSeq = 0;
465
+
466
+ // url(#localRef) and plain values only — no url(http…), no javascript:.
467
+ // `prefix` namespaces id definitions and their url(#…) references together.
468
+ function safeAttrValue(name, value, prefix) {
469
+ var v = String(value == null ? '' : value);
470
+ // Strip nothing; reject outright. Control chars are how javascript: is hidden.
471
+ var probe = v.replace(/[\u0000-\u0020\u007f-\u00a0]/g, '').toLowerCase();
472
+ if (probe.indexOf('javascript:') !== -1) return null;
473
+ if (probe.indexOf('data:text') !== -1) return null;
474
+ if (probe.indexOf('&#') !== -1) return null;
475
+ // Any url() must be a same-document fragment reference.
476
+ if (probe.indexOf('url(') !== -1 && !/^url\(#[a-z0-9_.:-]+\)$/i.test(probe)) return null;
477
+ if (name === 'id') {
478
+ if (!/^[a-zA-Z][\w.:-]*$/.test(v)) return null;
479
+ return prefix + v;
480
+ }
481
+ // Rewrite a reference so it points at THIS instance's namespaced definition.
482
+ var m = /^url\(#([\w.:-]+)\)$/.exec(v);
483
+ if (m) return 'url(#' + prefix + m[1] + ')';
484
+ return v;
485
+ }
486
+
487
+ function rebuildSvgNode(src, out, depth, prefix) {
488
+ if (depth > 24) return false; // pathological nesting
489
+ var kids = src.childNodes;
490
+ for (var i = 0; i < kids.length; i++) {
491
+ var n = kids[i];
492
+ if (n.nodeType === 3) { // text (only inside title/desc)
493
+ var pt = out.nodeName.toLowerCase();
494
+ if (pt === 'title' || pt === 'desc') out.appendChild(document.createTextNode(n.nodeValue));
495
+ continue;
496
+ }
497
+ if (n.nodeType !== 1) continue; // drop comments, CDATA, PIs
498
+ var tag = String(n.nodeName || '').toLowerCase();
499
+ if (!Object.prototype.hasOwnProperty.call(SVG_TAGS, tag)) return false; // bail, don't skip
500
+ var fresh = document.createElementNS(SVG_NS, n.nodeName);
501
+ var attrs = n.attributes || [];
502
+ for (var a = 0; a < attrs.length; a++) {
503
+ var an = String(attrs[a].name || '').toLowerCase();
504
+ if (/^on/i.test(an)) return false; // event handler present -> reject whole logo
505
+ if (an === 'href' || an === 'xlink:href' || an.indexOf('xlink') === 0) return false;
506
+ if (!Object.prototype.hasOwnProperty.call(SVG_ATTRS, an)) continue; // unknown -> just omit
507
+ var val = safeAttrValue(an, attrs[a].value, prefix);
508
+ if (val === null) continue;
509
+ try { fresh.setAttribute(SVG_ATTRS[an], val); } catch (e) { /* ignore */ }
510
+ }
511
+ if (!rebuildSvgNode(n, fresh, depth + 1, prefix)) return false;
512
+ out.appendChild(fresh);
513
+ }
514
+ return true;
515
+ }
516
+
517
+ // Raw SVG string -> freshly built, safe <svg> element, or null.
518
+ function sanitizeSvg(markup) {
519
+ var s = str(markup);
520
+ if (!s || s.length > 512 * 1024) return null;
521
+ if (!/^\s*<svg[\s>]/i.test(s)) return null; // must be an SVG root
522
+ var doc;
523
+ try {
524
+ doc = new DOMParser().parseFromString(s, 'image/svg+xml');
525
+ } catch (e) { return null; }
526
+ if (!doc) return null;
527
+ if (doc.getElementsByTagName('parsererror').length) return null;
528
+ var srcRoot = doc.documentElement;
529
+ if (!srcRoot || String(srcRoot.nodeName).toLowerCase() !== 'svg') return null;
530
+
531
+ // Unique per sanitised instance, so four copies of one asset never collide.
532
+ var prefix = 'ck' + (++svgSeq) + '-';
533
+
534
+ var svg = document.createElementNS(SVG_NS, 'svg');
535
+ var ra = srcRoot.attributes || [];
536
+ for (var i = 0; i < ra.length; i++) {
537
+ var an = String(ra[i].name || '').toLowerCase();
538
+ if (/^on/i.test(an)) return null;
539
+ if (an.indexOf('xlink') === 0 || an === 'href') return null;
540
+ if (!Object.prototype.hasOwnProperty.call(SVG_ATTRS, an)) continue;
541
+ var val = safeAttrValue(an, ra[i].value, prefix);
542
+ if (val === null) continue;
543
+ try { svg.setAttribute(SVG_ATTRS[an], val); } catch (e) {}
544
+ }
545
+ if (!rebuildSvgNode(srcRoot, svg, 0, prefix)) return null;
546
+ return svg;
547
+ }
548
+
549
+ /* Image-source logos.
550
+ Only http(s) and image data: URIs. data:text/html is a navigation/XSS
551
+ vector via <img>-adjacent contexts, and any other scheme is rejected.
552
+
553
+ NOTE FOR INTEGRATORS: an https:// logo is an external network request that
554
+ fires BEFORE the visitor has consented to anything. It leaks IP, User-Agent
555
+ and Referer to whoever hosts the file. ConsentKit therefore recommends an
556
+ inline SVG string or a data: URI, both of which are entirely local. An
557
+ external URL still works — it is the integrator's call, made knowingly —
558
+ and we send referrerpolicy=no-referrer to reduce what leaks. */
559
+ function safeImgSrc(value) {
560
+ var v = str(value);
561
+ if (!v) return null;
562
+ if (/^https?:\/\//i.test(v)) return v;
563
+ if (/^data:image\/(svg\+xml|png|jpe?g|webp|gif|avif)[;,]/i.test(v)) return v;
564
+ return null;
565
+ }
566
+
567
+ // Default 18px and a 32px ceiling: the logo is a signature, not a header.
568
+ // Anything taller starts competing with the banner title.
569
+ function clampLogoHeight(v) {
570
+ var n = (typeof v === 'number') ? v : parseFloat(v);
571
+ if (!isFinite(n)) return 18;
572
+ if (n < 14) return 14;
573
+ if (n > 32) return 32;
574
+ return Math.round(n);
575
+ }
576
+
577
+ // Only http(s) links are made clickable; javascript:/data: never become hrefs.
578
+ function safeLinkUrl(value) {
579
+ var v = str(value);
580
+ if (!v) return null;
581
+ return /^https?:\/\//i.test(v) ? v : null;
582
+ }
583
+
584
+ function brandingCfg(cfg) {
585
+ var b = cfg && cfg.branding;
586
+ return (b && typeof b === 'object' && !Array.isArray(b)) ? b : null;
587
+ }
588
+
589
+ // One logo node (inline SVG or <img>), already sanitised. null when unusable.
590
+ function buildLogoNode(source, alt, decorative) {
591
+ if (!source) return null;
592
+ var node = null;
593
+ var s = str(source);
594
+ if (!s) return null;
595
+
596
+ if (/^\s*</.test(s)) {
597
+ node = sanitizeSvg(s); // raw markup -> rebuilt SVG
598
+ if (node) node.classList.add('ck-brand__logo');
599
+ } else {
600
+ var src = safeImgSrc(s);
601
+ if (!src) return null;
602
+ node = el('img', 'ck-brand__logo');
603
+ node.setAttribute('referrerpolicy', 'no-referrer');
604
+ node.setAttribute('decoding', 'async');
605
+ node.src = src;
606
+ node.alt = decorative ? '' : (alt || '');
607
+ }
608
+ // The SVG carries no accessible name of its own; the wrapper supplies one
609
+ // (or hides it, when a sibling already names the logo).
610
+ if (node && node.nodeName.toLowerCase() === 'svg') {
611
+ node.setAttribute('aria-hidden', 'true');
612
+ node.setAttribute('focusable', 'false');
613
+ }
614
+ return node;
615
+ }
616
+
617
+ /* Logo block for a banner/panel header.
618
+
619
+ Dark theme: branding.logoDark, when supplied, is rendered as a second node
620
+ and swapped purely in CSS. When it is absent the single main logo shows in
621
+ both themes — which is why the shipped ECOM Consult asset (wordmark
622
+ fill="white", built for dark backgrounds) belongs in logoDark, with a
623
+ dark-ink variant in logo. An <img>/data: logo cannot be recoloured by our
624
+ CSS at all, so two assets are the only route there; an inline SVG could in
625
+ principle inherit currentColor, but only if the asset is authored that way. */
626
+ function buildBrandLogo(cfg) {
627
+ var b = brandingCfg(cfg);
628
+ if (!b) return null;
629
+
630
+ var alt = str(b.logoAlt) || '';
631
+ var main = buildLogoNode(b.logo, alt, false);
632
+ if (!main) return null; // no valid logo -> render nothing
633
+
634
+ var dark = buildLogoNode(b.logoDark, alt, false);
635
+
636
+ var wrap = el('div', 'ck-brand');
637
+ if (dark) {
638
+ wrap.classList.add('ck-brand__has-dark');
639
+ main.classList.add('ck-brand__light');
640
+ dark.classList.add('ck-brand__dark');
641
+ }
642
+
643
+ var link = safeLinkUrl(b.logoUrl);
644
+ var host_ = wrap;
645
+ if (link) {
646
+ var a = el('a', 'ck-brand__link');
647
+ a.href = link;
648
+ a.target = '_blank';
649
+ a.rel = 'noopener noreferrer';
650
+ // Links are focusable by nature; the accessible name comes from logoAlt.
651
+ a.setAttribute('aria-label', alt || 'ConsentKit');
652
+ host_ = a;
653
+ wrap.appendChild(a);
654
+ }
655
+ host_.appendChild(main);
656
+ if (dark) host_.appendChild(dark);
657
+
658
+ // A non-linked logo must not be a tab stop. The <svg>/<img> is aria-hidden
659
+ // or alt="", so a visually-hidden-free text alternative is supplied here
660
+ // for the image case only when it is not already announced by the <img> alt.
661
+ if (!link && alt && main.nodeName.toLowerCase() === 'svg') {
662
+ wrap.setAttribute('role', 'img');
663
+ wrap.setAttribute('aria-label', alt);
664
+ }
665
+ return wrap;
666
+ }
667
+
668
+ // Per-mount CSS for logo height + the dark/light swap. Mirrors buildThemeCss.
669
+ function buildBrandCss(cfg) {
670
+ var b = brandingCfg(cfg);
671
+ if (!b) return '';
672
+ var h = clampLogoHeight(b.logoHeight);
673
+ var out = [':host{--ck-logo-h:' + h + 'px}'];
674
+
675
+ var theme = (cfg && cfg.theme) || {};
676
+ var mode = theme.mode;
677
+ if (mode !== 'light' && mode !== 'dark') mode = 'auto';
678
+
679
+ // Same cascade shape as buildThemeCss so the logo always agrees with the
680
+ // palette: auto mode follows prefers-color-scheme but a forced .ck-mode-light
681
+ // still wins, and .ck-mode-dark forces the dark asset outright.
682
+ function swap(prefix) {
683
+ return prefix + ' .ck-brand__has-dark .ck-brand__light{display:none}\n' +
684
+ prefix + ' .ck-brand__has-dark .ck-brand__dark{display:block}';
685
+ }
686
+ if (mode === 'auto') {
687
+ out.push('@media (prefers-color-scheme: dark){\n' +
688
+ swap(':host(:not(.ck-mode-light))') + '\n}');
689
+ }
690
+ out.push(swap(':host(.ck-mode-dark)'));
691
+ return out.join('\n');
692
+ }
693
+
694
+ /* Powered-by line. true -> localised default; object -> caller's text/url.
695
+ Rendered after the actions in DOM order and styled quiet on purpose. */
696
+ function buildPoweredBy(cfg) {
697
+ var b = brandingCfg(cfg);
698
+ if (!b) return null;
699
+ var pb = b.poweredBy;
700
+ if (!pb) return null; // false/undefined -> nothing
701
+
702
+ var text, url = null;
703
+ if (pb === true) {
704
+ text = T.poweredBy; // en fallback guaranteed by STR_KEYS
705
+ } else if (typeof pb === 'object' && !Array.isArray(pb)) {
706
+ text = str(pb.text) || T.poweredBy;
707
+ url = safeLinkUrl(pb.url);
708
+ } else {
709
+ return null;
710
+ }
711
+
712
+ var p = el('p', 'ck-powered');
713
+ if (url) {
714
+ var a = el('a', null, text);
715
+ a.href = url;
716
+ a.target = '_blank';
717
+ a.rel = 'noopener noreferrer';
718
+ p.appendChild(a);
719
+ } else {
720
+ p.appendChild(document.createTextNode(text));
721
+ }
722
+ return p;
723
+ }
724
+
725
+ function safeState() {
726
+ var ck = api();
727
+ var s = null;
728
+ try {
729
+ if (ck && typeof ck.getState === 'function') s = ck.getState();
730
+ } catch (e) { s = null; }
731
+ if (!s || typeof s !== 'object') s = { decided: false, categories: {} };
732
+ if (!s.categories || typeof s.categories !== 'object') s.categories = {};
733
+ return s;
734
+ }
735
+
736
+ function safeConfig() {
737
+ var ck = api();
738
+ var c = (ck && ck.config) || {};
739
+ return (c && typeof c === 'object') ? c : {};
740
+ }
741
+
742
+ /* ----------------------------------------------------------------- state */
743
+
744
+ var mounted = false;
745
+ var mountedSig = null;
746
+ var host = null, root = null;
747
+ var T = DICT.en;
748
+ var nodes = {}; // banner/panel/fab refs
749
+ var switches = {}; // category -> button
750
+ var panelOpen = false;
751
+ var lastFocus = null;
752
+
753
+ /* ---------------------------------------------------------------- build */
754
+
755
+ function activeOptIn(cfg) {
756
+ var cats = (cfg && cfg.categories) || {};
757
+ var out = [];
758
+ for (var i = 0; i < OPT_IN.length; i++) {
759
+ var k = OPT_IN[i];
760
+ var entry = cats[k];
761
+ // absent -> shown; explicit enabled:false -> hidden
762
+ if (entry && entry.enabled === false) continue;
763
+ out.push(k);
764
+ }
765
+ return out;
766
+ }
767
+
768
+ function cookiesFor(cfg, cat) {
769
+ var list = (cfg && cfg.cookieTable) || [];
770
+ if (!Array.isArray(list)) return [];
771
+ var out = [];
772
+ for (var i = 0; i < list.length; i++) {
773
+ var row = list[i];
774
+ if (row && typeof row === 'object' && String(row.category || '') === cat) out.push(row);
775
+ }
776
+ return out;
777
+ }
778
+
779
+ /* ----------------------------------------------------------------- theme */
780
+
781
+ // Built-in palettes. Dark values are picked for >= 4.5:1 text contrast.
782
+ var LIGHT_RADIUS = '10px';
783
+ var LIGHT = {
784
+ bg: '#fff', ink: '#1B2437', accent: '#2B50D8', onAccent: '#fff',
785
+ muted: '#5b6478', line: '#dfe3ea', soft: '#f4f6f9'
786
+ };
787
+ var DARK = {
788
+ bg: '#1A202D', ink: '#E6EAF4', accent: '#7B96F0', onAccent: '#12182A',
789
+ muted: '#A6B0C6', line: '#333C4F', soft: '#232B3A'
790
+ };
791
+
792
+ function str(v) {
793
+ return (typeof v === 'string' && v.trim()) ? v.trim() : null;
794
+ }
795
+
796
+ /* Config values are interpolated into the TEXT of a generated stylesheet, so
797
+ an unvalidated value can close the declaration and open rules of its own
798
+ ("10px;}.ck-btn--filled{display:none" hides "Reject all"). config.theme is
799
+ not trusted input: in standalone mode it comes straight from the embedding
800
+ page or an integrator's admin panel, with no server-side validation
801
+ anywhere in the path. So every value is matched against a strict grammar
802
+ for its kind and silently replaced by the token default when it does not
803
+ fit — a broken colour is a cosmetic bug, an injected rule is a defacement
804
+ and can strip the reject button, which is a consent-validity problem.
805
+
806
+ Sanitising happens at the entry points in buildThemeCss (the eight
807
+ config reads), not in tokenBlock(): by the time values reach derive() and
808
+ color-mix() they are already clean, and the built-in constants and
809
+ generated color-mix() strings must not be re-validated by this grammar. */
810
+ var RE_CSS = {
811
+ // #RGB / #RRGGBB / #RRGGBBAA (and #RGBA), rgb()/rgba()/hsl()/hsla() with
812
+ // numbers, commas, spaces, %, decimals and slashes, or a bare colour name.
813
+ color: /^(#[0-9a-fA-F]{3,8}|(rgb|rgba|hsl|hsla)\([0-9.,%\s/]+\)|[a-zA-Z]+)$/,
814
+ length: /^\d+(\.\d+)?(px|rem|em|%)$/
815
+ };
816
+ // Belt-and-braces: nothing that passed above may still carry CSS structure.
817
+ var RE_CSS_UNSAFE = /[;{}<>]/;
818
+
819
+ function sanitizeCssValue(kind, value, fallback) {
820
+ var v = str(value);
821
+ if (!v) return fallback;
822
+ var re = RE_CSS[kind];
823
+ if (!re || !re.test(v)) return fallback;
824
+ if (RE_CSS_UNSAFE.test(v)) return fallback;
825
+ return v;
826
+ }
827
+
828
+ // One :host{} block of custom properties for a resolved palette.
829
+ function tokenBlock(sel, p, radius) {
830
+ var d = [
831
+ '--ck-bg:' + p.bg,
832
+ '--ck-ink:' + p.ink,
833
+ '--ck-accent:' + p.accent,
834
+ '--ck-on-accent:' + p.onAccent,
835
+ '--ck-muted:' + p.muted,
836
+ '--ck-line:' + p.line,
837
+ '--ck-soft:' + p.soft
838
+ ];
839
+ if (radius) d.push('--ck-radius:' + radius);
840
+ return sel + '{' + d.join(';') + '}';
841
+ }
842
+
843
+ // Derived tokens follow the explicit bg/ink the caller supplied, so a custom
844
+ // light palette keeps readable muted/line/soft values.
845
+ function derive(base, bg, ink) {
846
+ var p = {
847
+ bg: bg, ink: ink, accent: base.accent, onAccent: base.onAccent,
848
+ muted: base.muted, line: base.line, soft: base.soft
849
+ };
850
+ if (bg !== base.bg || ink !== base.ink) {
851
+ p.muted = 'color-mix(in srgb, ' + ink + ' 62%, ' + bg + ')';
852
+ p.line = 'color-mix(in srgb, ' + ink + ' 14%, ' + bg + ')';
853
+ p.soft = 'color-mix(in srgb, ' + ink + ' 5%, ' + bg + ')';
854
+ }
855
+ return p;
856
+ }
857
+
858
+ function buildThemeCss(cfg) {
859
+ var theme = (cfg && cfg.theme) || {};
860
+ var dk = (theme.dark && typeof theme.dark === 'object') ? theme.dark : {};
861
+ // Every value below is interpolated into stylesheet text — see
862
+ // sanitizeCssValue(). A rejected value falls back to the token default, so
863
+ // derive()'s "bg !== base.bg" check collapses to exactly the built-in
864
+ // palette rather than a half-substituted one.
865
+ var radius = sanitizeCssValue('length', theme.radius, LIGHT_RADIUS);
866
+
867
+ // Light: config overrides on top of the built-in light palette.
868
+ var light = derive(LIGHT,
869
+ sanitizeCssValue('color', theme.bg, LIGHT.bg),
870
+ sanitizeCssValue('color', theme.ink, LIGHT.ink));
871
+ if (str(theme.accent)) light.accent = sanitizeCssValue('color', theme.accent, LIGHT.accent);
872
+
873
+ // Dark: theme.dark overrides on top of the built-in dark palette.
874
+ // A light-only theme.accent deliberately does NOT carry into dark — the
875
+ // default #2B50D8 on #1A202D is ~2.5:1 and would fail AA.
876
+ var dark = derive(DARK,
877
+ sanitizeCssValue('color', dk.bg, DARK.bg),
878
+ sanitizeCssValue('color', dk.ink, DARK.ink));
879
+ if (str(dk.accent)) dark.accent = sanitizeCssValue('color', dk.accent, DARK.accent);
880
+ if (str(dk.onAccent)) dark.onAccent = sanitizeCssValue('color', dk.onAccent, DARK.onAccent);
881
+
882
+ var mode = theme.mode;
883
+ if (mode !== 'light' && mode !== 'dark') mode = 'auto';
884
+
885
+ var out = [tokenBlock(':host', light, radius)];
886
+ if (mode === 'auto') {
887
+ // forced-light class must still beat a dark system preference
888
+ out.push('@media (prefers-color-scheme: dark){' +
889
+ tokenBlock(':host(:not(.ck-mode-light))', dark, null) + '}');
890
+ }
891
+ out.push(tokenBlock(':host(.ck-mode-dark)', dark, null));
892
+
893
+ // color-mix fallback, per context, so a custom palette without color-mix
894
+ // still lands on readable static values rather than transparent.
895
+ out.push('@supports not (color: color-mix(in srgb, #000 50%, #fff)){' +
896
+ ':host{--ck-muted:' + LIGHT.muted + ';--ck-line:' + LIGHT.line + ';--ck-soft:' + LIGHT.soft + '}' +
897
+ (mode === 'auto'
898
+ ? '@media (prefers-color-scheme: dark){:host(:not(.ck-mode-light)){--ck-muted:' + DARK.muted +
899
+ ';--ck-line:' + DARK.line + ';--ck-soft:' + DARK.soft + '}}'
900
+ : '') +
901
+ ':host(.ck-mode-dark){--ck-muted:' + DARK.muted + ';--ck-line:' + DARK.line +
902
+ ';--ck-soft:' + DARK.soft + '}}');
903
+
904
+ return { css: out.join('\n'), mode: mode };
905
+ }
906
+
907
+ function applyTheme(cfg) {
908
+ if (!host || !nodes.themeStyle) return;
909
+ var built = buildThemeCss(cfg);
910
+ // Brand rules ride along in the same sheet: the dark/light logo swap depends
911
+ // on theme.mode, so it must be rebuilt whenever the palette is.
912
+ nodes.themeStyle.textContent = built.css + '\n' + buildBrandCss(cfg); // replace, never append
913
+ host.classList.remove('ck-mode-dark', 'ck-mode-light');
914
+ if (built.mode === 'dark') host.classList.add('ck-mode-dark');
915
+ else if (built.mode === 'light') host.classList.add('ck-mode-light');
916
+ }
917
+
918
+ function makeSwitch(cat, locked) {
919
+ var b = el('button', 'ck-switch');
920
+ b.type = 'button';
921
+ b.setAttribute('role', 'switch');
922
+ b.setAttribute('aria-checked', locked ? 'true' : 'false');
923
+ b.dataset.cat = cat;
924
+ if (locked) {
925
+ b.disabled = true;
926
+ b.setAttribute('aria-disabled', 'true');
927
+ } else {
928
+ b.addEventListener('click', function () {
929
+ var on = b.getAttribute('aria-checked') === 'true';
930
+ b.setAttribute('aria-checked', on ? 'false' : 'true');
931
+ });
932
+ }
933
+ return b;
934
+ }
935
+
936
+ function buildCategory(cfg, cat) {
937
+ var meta = T.cat[cat] || { title: cat, desc: '' };
938
+ var locked = cat === 'necessary';
939
+
940
+ var wrap = el('div', 'ck-cat');
941
+ var top = el('div', 'ck-cat__top');
942
+ var txt = el('div', 'ck-cat__txt');
943
+
944
+ var nameId = 'ck-cat-' + cat;
945
+ var name = el('div', 'ck-cat__name');
946
+ var nameSpan = el('span', null, meta.title);
947
+ nameSpan.id = nameId;
948
+ name.appendChild(nameSpan);
949
+ if (locked) name.appendChild(el('span', 'ck-cat__badge', T.alwaysOn));
950
+ txt.appendChild(name);
951
+
952
+ var descId = nameId + '-desc';
953
+ var desc = el('p', 'ck-cat__desc', meta.desc);
954
+ desc.id = descId;
955
+ txt.appendChild(desc);
956
+
957
+ var sw = makeSwitch(cat, locked);
958
+ sw.setAttribute('aria-labelledby', nameId);
959
+ sw.setAttribute('aria-describedby', descId);
960
+ switches[cat] = sw;
961
+
962
+ top.appendChild(txt);
963
+ top.appendChild(sw);
964
+ wrap.appendChild(top);
965
+
966
+ var rows = cookiesFor(cfg, cat);
967
+ if (rows.length) {
968
+ var det = el('details', 'ck-det');
969
+ var sum = el('summary');
970
+ sum.appendChild(document.createTextNode(T.cookiesIn + ' (' + rows.length + ')'));
971
+ det.appendChild(sum);
972
+
973
+ var tw = el('div', 'ck-tablewrap');
974
+ var table = el('table');
975
+ var thead = el('thead');
976
+ var htr = el('tr');
977
+ var heads = [T.colName, T.colVendor, T.colPurpose, T.colExpiry];
978
+ for (var h = 0; h < heads.length; h++) {
979
+ var th = el('th', null, heads[h]);
980
+ th.setAttribute('scope', 'col');
981
+ htr.appendChild(th);
982
+ }
983
+ thead.appendChild(htr);
984
+ table.appendChild(thead);
985
+
986
+ var tbody = el('tbody');
987
+ for (var r = 0; r < rows.length; r++) {
988
+ var row = rows[r];
989
+ var tr = el('tr');
990
+ tr.appendChild(el('td', 'ck-mono', String(row.name == null ? '—' : row.name)));
991
+ tr.appendChild(el('td', null, String(row.vendor == null ? '—' : row.vendor)));
992
+ tr.appendChild(el('td', null, String(row.purpose == null ? '—' : row.purpose)));
993
+ tr.appendChild(el('td', null, String(row.expiry == null ? '—' : row.expiry)));
994
+ tbody.appendChild(tr);
995
+ }
996
+ table.appendChild(tbody);
997
+ tw.appendChild(table);
998
+ det.appendChild(tw);
999
+ wrap.appendChild(det);
1000
+ }
1001
+
1002
+ return wrap;
1003
+ }
1004
+
1005
+ // Unknown type -> bar/bottom. Known type with an unrecognized position ->
1006
+ // that type's own default (bar: bottom, box: bottom-left — the side away
1007
+ // from the chat widgets and scroll-to-top buttons most sites put on the right).
1008
+ function resolveLayout(cfg) {
1009
+ var layout = (cfg && cfg.layout) || {};
1010
+ var type = layout.type;
1011
+ if (type !== 'modal' && type !== 'box') type = 'bar';
1012
+ var pos = layout.position;
1013
+ if (type === 'bar') pos = (pos === 'top') ? 'top' : 'bottom';
1014
+ else if (type === 'box') pos = (pos === 'bottom-right') ? 'bottom-right' : 'bottom-left';
1015
+ else pos = null; // modal is centered
1016
+ return { type: type, position: pos };
1017
+ }
1018
+
1019
+ function buildBanner(cfg) {
1020
+ var lay = resolveLayout(cfg);
1021
+ var isModal = lay.type === 'modal';
1022
+
1023
+ var scrim = el('div', 'ck-scrim ck-hidden'); // decorative, never blocks scroll
1024
+ scrim.setAttribute('aria-hidden', 'true');
1025
+ if (!isModal) scrim.classList.add('ck-hidden');
1026
+
1027
+ var b = el('section', 'ck-banner ck-hidden');
1028
+ b.className = 'ck-banner ck-hidden ck-banner--' + lay.type +
1029
+ (lay.position ? ' ck-pos-' + lay.position : '');
1030
+ b.setAttribute('role', 'region');
1031
+ b.setAttribute('aria-label', T.bannerLabel);
1032
+
1033
+ var body = el('div', 'ck-banner__body');
1034
+ var h = el('h2', null, T.bannerTitle);
1035
+ h.id = 'ck-banner-title';
1036
+ body.appendChild(h);
1037
+
1038
+ var p = el('p');
1039
+ p.appendChild(document.createTextNode(T.bannerText + ' '));
1040
+ var link = el('a', 'ck-banner__more', T.more);
1041
+ link.href = '#';
1042
+ p.appendChild(link);
1043
+ body.appendChild(p);
1044
+
1045
+ var pb = buildPoweredBy(cfg);
1046
+ var brand = buildBrandLogo(cfg);
1047
+ var foot = null;
1048
+ if (brand || pb) {
1049
+ foot = el('div', 'ck-foot');
1050
+ if (brand) foot.appendChild(brand);
1051
+ if (pb) foot.appendChild(pb);
1052
+ }
1053
+
1054
+ // Where the attribution goes depends on the layout's flow direction.
1055
+ // bar: text and actions sit side by side and are centred against each
1056
+ // other, so a full-width row underneath would stretch the first row and
1057
+ // leave the buttons floating — the foot belongs at the end of the text
1058
+ // column instead. box/modal stack vertically, so it simply follows the
1059
+ // buttons, which is also the correct reading order there.
1060
+ if (foot && lay.type === 'bar') body.appendChild(foot);
1061
+ b.appendChild(body);
1062
+
1063
+ var actions = el('div', 'ck-actions');
1064
+ var accept = el('button', 'ck-btn ck-btn--filled', T.acceptAll);
1065
+ accept.type = 'button';
1066
+ var reject = el('button', 'ck-btn ck-btn--filled', T.rejectAll);
1067
+ reject.type = 'button';
1068
+ var custom = el('button', 'ck-btn ck-btn--outline', T.customize);
1069
+ custom.type = 'button';
1070
+
1071
+ accept.addEventListener('click', function () { doAcceptAll(); });
1072
+ reject.addEventListener('click', function () { doRejectAll(); });
1073
+ custom.addEventListener('click', function () { openPanel(custom); });
1074
+
1075
+ actions.appendChild(accept);
1076
+ actions.appendChild(reject);
1077
+ actions.appendChild(custom);
1078
+ b.appendChild(actions);
1079
+
1080
+ // box/modal: the foot follows the buttons (see the note above).
1081
+ if (foot && lay.type !== 'bar') b.appendChild(foot);
1082
+
1083
+
1084
+ nodes.scrim = scrim;
1085
+ nodes.banner = b;
1086
+ nodes.bannerModal = isModal;
1087
+ root.appendChild(scrim);
1088
+ root.appendChild(b);
1089
+ }
1090
+
1091
+ function buildPanel(cfg) {
1092
+ var scrim = el('div', 'ck-panel-scrim ck-hidden');
1093
+ scrim.setAttribute('aria-hidden', 'true');
1094
+
1095
+ var p = el('div', 'ck-panel ck-hidden');
1096
+ p.setAttribute('role', 'dialog');
1097
+ p.setAttribute('aria-modal', 'true');
1098
+ p.setAttribute('aria-label', T.panelTitle);
1099
+ p.tabIndex = -1;
1100
+
1101
+ var head = el('div', 'ck-panel__head');
1102
+ var htxt = el('div');
1103
+ htxt.appendChild(el('h2', null, T.panelTitle));
1104
+ htxt.appendChild(el('p', null, T.panelIntro));
1105
+ head.appendChild(htxt);
1106
+
1107
+ var x = el('button', 'ck-x');
1108
+ x.type = 'button';
1109
+ x.setAttribute('aria-label', T.close);
1110
+ x.appendChild(document.createTextNode('✕'));
1111
+ x.addEventListener('click', function () { closePanel(); });
1112
+ head.appendChild(x);
1113
+ p.appendChild(head);
1114
+
1115
+ var body = el('div', 'ck-panel__body');
1116
+ var cats = ['necessary'].concat(activeOptIn(cfg));
1117
+ for (var i = 0; i < cats.length; i++) body.appendChild(buildCategory(cfg, cats[i]));
1118
+ p.appendChild(body);
1119
+
1120
+ var foot = el('div', 'ck-panel__foot');
1121
+ var save = el('button', 'ck-btn ck-btn--filled', T.save);
1122
+ save.type = 'button';
1123
+ var acc = el('button', 'ck-btn ck-btn--outline', T.acceptAll);
1124
+ acc.type = 'button';
1125
+ var rej = el('button', 'ck-btn ck-btn--outline', T.rejectAll);
1126
+ rej.type = 'button';
1127
+
1128
+ save.addEventListener('click', function () { doSave(); });
1129
+ acc.addEventListener('click', function () { doAcceptAll(); });
1130
+ rej.addEventListener('click', function () { doRejectAll(); });
1131
+
1132
+ foot.appendChild(save);
1133
+ foot.appendChild(acc);
1134
+ foot.appendChild(rej);
1135
+ // Same attribution foot as the banner: mark and credit sign the bottom,
1136
+ // below the action buttons, never the panel heading.
1137
+ var pfoot = buildPoweredBy(cfg);
1138
+ var pbrand = buildBrandLogo(cfg);
1139
+ if (pbrand || pfoot) {
1140
+ var pfootWrap = el('div', 'ck-foot');
1141
+ if (pbrand) pfootWrap.appendChild(pbrand);
1142
+ if (pfoot) pfootWrap.appendChild(pfoot);
1143
+ foot.appendChild(pfootWrap);
1144
+ }
1145
+ p.appendChild(foot);
1146
+
1147
+ p.addEventListener('keydown', onPanelKeydown);
1148
+
1149
+ nodes.panelScrim = scrim;
1150
+ nodes.panel = p;
1151
+ root.appendChild(scrim);
1152
+ root.appendChild(p);
1153
+ }
1154
+
1155
+ function buildFab() {
1156
+ var f = el('button', 'ck-fab ck-hidden');
1157
+ f.type = 'button';
1158
+ f.setAttribute('aria-label', T.floating);
1159
+ f.title = T.floating;
1160
+ f.innerHTML = COOKIE_ICON; // static literal, no config data
1161
+ f.addEventListener('click', function () { openPanel(f); });
1162
+ nodes.fab = f;
1163
+ root.appendChild(f);
1164
+ }
1165
+
1166
+ /* -------------------------------------------------------------- a11y/trap */
1167
+
1168
+ function focusables() {
1169
+ if (!nodes.panel) return [];
1170
+ var sel = 'button:not([disabled]),a[href],summary,input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
1171
+ var all = nodes.panel.querySelectorAll(sel);
1172
+ var out = [];
1173
+ for (var i = 0; i < all.length; i++) {
1174
+ var n = all[i];
1175
+ if (n.hasAttribute('disabled')) continue;
1176
+ if (n.offsetParent === null && n.getClientRects().length === 0) continue;
1177
+ out.push(n);
1178
+ }
1179
+ return out;
1180
+ }
1181
+
1182
+ function onPanelKeydown(e) {
1183
+ if (e.key === 'Escape' || e.key === 'Esc') {
1184
+ e.preventDefault();
1185
+ e.stopPropagation();
1186
+ closePanel();
1187
+ return;
1188
+ }
1189
+ if (e.key !== 'Tab') return;
1190
+ var list = focusables(); // queried live: <details> changes the set
1191
+ if (!list.length) { e.preventDefault(); return; }
1192
+ var current = root.activeElement || document.activeElement;
1193
+ var idx = list.indexOf(current);
1194
+ var next;
1195
+ if (e.shiftKey) next = idx <= 0 ? list[list.length - 1] : list[idx - 1];
1196
+ else next = (idx === -1 || idx === list.length - 1) ? list[0] : list[idx + 1];
1197
+ e.preventDefault();
1198
+ next.focus();
1199
+ }
1200
+
1201
+ /* ---------------------------------------------------------------- actions */
1202
+
1203
+ function readSwitches() {
1204
+ var out = {};
1205
+ for (var i = 0; i < OPT_IN.length; i++) {
1206
+ var k = OPT_IN[i];
1207
+ var sw = switches[k];
1208
+ out[k] = !!(sw && sw.getAttribute('aria-checked') === 'true');
1209
+ }
1210
+ return out;
1211
+ }
1212
+
1213
+ function doAcceptAll() {
1214
+ var ck = api();
1215
+ try { if (ck && typeof ck.accept === 'function') ck.accept('all'); } catch (e) {}
1216
+ closePanel(true);
1217
+ syncFromState();
1218
+ }
1219
+
1220
+ function doRejectAll() {
1221
+ var ck = api();
1222
+ try { if (ck && typeof ck.rejectAll === 'function') ck.rejectAll(); } catch (e) {}
1223
+ closePanel(true);
1224
+ syncFromState();
1225
+ }
1226
+
1227
+ // Saving without touching anything is a valid refusal of every opt-in category.
1228
+ function doSave() {
1229
+ var ck = api();
1230
+ var choice = readSwitches();
1231
+ try { if (ck && typeof ck.accept === 'function') ck.accept(choice); } catch (e) {}
1232
+ closePanel(true);
1233
+ syncFromState();
1234
+ }
1235
+
1236
+ /* ------------------------------------------------------------ open/close */
1237
+
1238
+ function openPanel(invoker) {
1239
+ if (!mounted || !nodes.panel) return;
1240
+ lastFocus = invoker || root.activeElement || document.activeElement;
1241
+ syncSwitches(safeState());
1242
+ nodes.panelScrim.classList.remove('ck-hidden');
1243
+ nodes.panel.classList.remove('ck-hidden');
1244
+ panelOpen = true;
1245
+ try { nodes.panel.focus(); } catch (e) {}
1246
+ }
1247
+
1248
+ function closePanel(skipRestore) {
1249
+ if (!nodes.panel) return;
1250
+ var wasOpen = panelOpen;
1251
+ nodes.panelScrim.classList.add('ck-hidden');
1252
+ nodes.panel.classList.add('ck-hidden');
1253
+ panelOpen = false;
1254
+ if (wasOpen && !skipRestore && lastFocus && typeof lastFocus.focus === 'function') {
1255
+ try { if (lastFocus.isConnected !== false) lastFocus.focus(); } catch (e) {}
1256
+ }
1257
+ lastFocus = null;
1258
+ }
1259
+
1260
+ /* ------------------------------------------------------------------ sync */
1261
+
1262
+ function syncSwitches(state) {
1263
+ var cats = (state && state.categories) || {};
1264
+ for (var i = 0; i < OPT_IN.length; i++) {
1265
+ var k = OPT_IN[i];
1266
+ var sw = switches[k];
1267
+ if (!sw) continue;
1268
+ // before a decision every opt-in switch stays off
1269
+ var on = state && state.decided ? cats[k] === true : false;
1270
+ sw.setAttribute('aria-checked', on ? 'true' : 'false');
1271
+ }
1272
+ if (switches.necessary) switches.necessary.setAttribute('aria-checked', 'true');
1273
+ }
1274
+
1275
+ // Idempotent: safe to call from ck:init, ck:change and right after our own API calls.
1276
+ function syncFromState(state) {
1277
+ if (!mounted) return;
1278
+ var s = state || safeState();
1279
+ var decided = !!s.decided;
1280
+
1281
+ if (nodes.banner) nodes.banner.classList.toggle('ck-hidden', decided);
1282
+ if (nodes.scrim) nodes.scrim.classList.toggle('ck-hidden', decided || !nodes.bannerModal);
1283
+ if (nodes.fab) nodes.fab.classList.toggle('ck-hidden', !decided);
1284
+ if (!panelOpen) syncSwitches(s);
1285
+ }
1286
+
1287
+ /* ----------------------------------------------------------------- mount */
1288
+
1289
+ // Structural inputs: a change to any of these needs a rebuild, not a restyle.
1290
+ // Branding belongs here because it produces DOM, not just styling: mount() is
1291
+ // one-shot, so a config that gains a logo after the first ck:init would
1292
+ // otherwise take the applyTheme()-only path and never render it.
1293
+ function brandSignature(cfg) {
1294
+ var b = brandingCfg(cfg);
1295
+ if (!b) return '-';
1296
+ var pb = b.poweredBy;
1297
+ var pbSig = (pb && typeof pb === 'object')
1298
+ ? 'o:' + String(pb.text || '') + ':' + String(pb.url || '')
1299
+ : String(!!pb);
1300
+ // Logos are hashed by length + head so a long data: URI does not bloat the key.
1301
+ function tag(v) {
1302
+ var s = str(v);
1303
+ return s ? (s.length + ':' + s.slice(0, 32)) : '-';
1304
+ }
1305
+ return [
1306
+ tag(b.logo), tag(b.logoDark), String(b.logoAlt || ''),
1307
+ String(clampLogoHeight(b.logoHeight)), String(b.logoUrl || ''), pbSig
1308
+ ].join('~');
1309
+ }
1310
+
1311
+ function signature(cfg) {
1312
+ var c = cfg || {};
1313
+ var lay = resolveLayout(c);
1314
+ var table = c.cookieTable;
1315
+ return [
1316
+ String(c.language || 'auto'),
1317
+ lay.type, String(lay.position),
1318
+ Array.isArray(table) ? table.length : 0,
1319
+ brandSignature(c)
1320
+ ].join('|');
1321
+ }
1322
+
1323
+ function remount(cfg) {
1324
+ mounted = false;
1325
+ panelOpen = false;
1326
+ lastFocus = null;
1327
+ mount(cfg);
1328
+ }
1329
+
1330
+ function mount(cfg) {
1331
+ if (mounted) return;
1332
+ if (!document.body) {
1333
+ document.addEventListener('DOMContentLoaded', function () { mount(safeConfig()); }, { once: true });
1334
+ return;
1335
+ }
1336
+
1337
+ var table = localeTable(); // read at render time
1338
+ T = buildStrings(resolveLang(cfg && cfg.language, table), table);
1339
+
1340
+ host = document.getElementById('ck-root');
1341
+ if (!host) {
1342
+ host = document.createElement('div');
1343
+ host.id = 'ck-root';
1344
+ document.body.appendChild(host);
1345
+ }
1346
+ root = host.shadowRoot || host.attachShadow({ mode: 'open' });
1347
+ root.innerHTML = '';
1348
+
1349
+ var style = document.createElement('style');
1350
+ style.textContent = CSS;
1351
+ root.appendChild(style);
1352
+
1353
+ switches = {};
1354
+ nodes = {};
1355
+
1356
+ // Palette sheet comes after the base sheet so its :host tokens win.
1357
+ nodes.themeStyle = document.createElement('style');
1358
+ root.appendChild(nodes.themeStyle);
1359
+ applyTheme(cfg);
1360
+
1361
+ buildBanner(cfg);
1362
+ buildPanel(cfg);
1363
+ buildFab();
1364
+
1365
+ mounted = true;
1366
+ mountedSig = signature(cfg);
1367
+ syncFromState();
1368
+ }
1369
+
1370
+ /* ---------------------------------------------------------------- events */
1371
+
1372
+ // SSR-safe: with no DOM there is nothing to render or listen to, so importing
1373
+ // this file in Node is a no-op rather than a throw (mirrors the core).
1374
+ if (typeof document === 'undefined') return;
1375
+
1376
+ document.addEventListener('ck:init', function (e) {
1377
+ var d = (e && e.detail) || {};
1378
+ var cfg = d.config || safeConfig();
1379
+ if (!mounted) {
1380
+ mount(cfg);
1381
+ } else if (signature(cfg) !== mountedSig) {
1382
+ remount(cfg); // language/layout changed since the first render
1383
+ } else {
1384
+ applyTheme(cfg); // palette-only changes need no rebuild
1385
+ }
1386
+ syncFromState(d.state);
1387
+ });
1388
+
1389
+ document.addEventListener('ck:change', function (e) {
1390
+ var d = (e && e.detail) || {};
1391
+ syncFromState(d.state);
1392
+ });
1393
+
1394
+ // Insurance: sync is idempotent, so a core that only signals the first choice
1395
+ // via ck:consent still updates the UI.
1396
+ document.addEventListener('ck:consent', function (e) {
1397
+ var d = (e && e.detail) || {};
1398
+ syncFromState(d.state);
1399
+ });
1400
+
1401
+ document.addEventListener('ck:ui:open-preferences', function () {
1402
+ if (!mounted) mount(safeConfig());
1403
+ openPanel(null);
1404
+ });
1405
+
1406
+ document.addEventListener('ck:ui:close', function () {
1407
+ closePanel();
1408
+ });
1409
+
1410
+ // Fallback for a missed ck:init (this file loaded after init() already ran).
1411
+ // Deferred to a macrotask on purpose: the core publishes a DEFAULT config at
1412
+ // parse time, so mounting synchronously here would render with those defaults
1413
+ // before the real init() config arrives and — mount() being one-shot — lock in
1414
+ // the wrong language and layout. By the time the timeout runs, a normally
1415
+ // ordered page has already dispatched ck:init and mounted, so this no-ops;
1416
+ // only a genuinely missed ck:init reaches mount(), and by then
1417
+ // ConsentKit.config holds the merged real config.
1418
+ setTimeout(function () {
1419
+ if (!mounted && api() && api().config) mount(safeConfig());
1420
+ }, 0);
1421
+ })();