@ecomconsult/consentkit 0.3.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ck-saas.js CHANGED
@@ -122,15 +122,43 @@
122
122
  // ---------------------------------------------------------------------------
123
123
  // Boot
124
124
  // ---------------------------------------------------------------------------
125
+ // The service's tracker overrides (§1.3). Applied through the core's
126
+ // _extendHostDb so the classification the scanner used server-side is the
127
+ // classification the browser uses on the same page.
128
+ //
129
+ // ORDER MATTERS: this must run BEFORE CK.init(), because init() performs the
130
+ // initial scan and the first applyConsentToDom() pass. A host merged after
131
+ // that pass would not be recognised on the scripts already in the markup.
132
+ function applyHostDb(config) {
133
+ try {
134
+ var map = config && config.hostdb;
135
+ if (!map || typeof map !== 'object') { return 0; }
136
+ if (typeof CK._extendHostDb !== 'function') {
137
+ // An older core on the page (config contract is newer than the client).
138
+ warn('this core does not support config.hostdb — update ck-core.js to 0.4.0 or later.');
139
+ return 0;
140
+ }
141
+ return CK._extendHostDb(map);
142
+ } catch (e) { return 0; }
143
+ }
144
+
125
145
  function initWith(config, why) {
126
146
  activeConfig = config;
147
+ applyHostDb(config);
127
148
  try { CK.init(config); } catch (e) { error('init() failed: ' + (e && e.message)); }
128
149
  if (why) { /* reserved for diagnostics */ }
129
150
  }
130
151
 
131
- // Strict mode: banner shows, every opt-in category stays off, no journal.
152
+ // Strict FALLBACK: banner shows, every opt-in category stays off, no journal.
132
153
  // policyVersion 'strict-fallback' deliberately mismatches any stored consent,
133
154
  // so a previous decision is not silently reused when the server is unreachable.
155
+ //
156
+ // Not to be confused with `blocking.mode: 'strict'` (SPEC §2), which is a
157
+ // different thing that happens to share the word: this path deliberately does
158
+ // NOT set it. When the config server is unreachable we know nothing about the
159
+ // site, and turning on the mode that blocks every third-party script would
160
+ // break a page precisely when its owner has no way to configure an allowlist.
161
+ // Known trackers are still blocked, as always.
134
162
  function initStrict(reason) {
135
163
  warn('config unavailable (' + reason + ') — strict fallback: banner shown, all opt-in categories denied, journal disabled.');
136
164
  initWith({ policyVersion: 'strict-fallback' });
@@ -194,6 +222,13 @@
194
222
  // re-initialising would swap ConsentKit.config identity mid-session.
195
223
  // It takes effect on the next page load.
196
224
  writeCache(r.etag, r.config);
225
+ // `hostdb` is the deliberate exception. Extending the map has exactly the
226
+ // semantics _extendHostDb documents for a call after init(): nothing
227
+ // already inserted is re-evaluated, but everything inserted from now on
228
+ // is classified against the new overrides. Waiting a whole page load to
229
+ // start blocking a newly classified tracker would be worse, and unlike
230
+ // the rest of the config this cannot change ConsentKit.config identity.
231
+ applyHostDb(r.config);
197
232
  }, function (reason) {
198
233
  warn('background revalidation failed (' + reason + '); continuing with cached config.');
199
234
  });
@@ -0,0 +1,511 @@
1
+ /* ConsentKit branding extension — logo + "powered by" line for the banner and panel.
2
+
3
+ OPTIONAL BY DESIGN. src/ck-ui.js renders branding only when this file has
4
+ registered itself; with the file absent it draws no logo, no attribution and
5
+ no branding CSS, and nothing errors. That is the whole point of the split:
6
+ an integrator who never sets `config.branding` should not ship ~19 KB of SVG
7
+ sanitiser and brand styling to every visitor, and tools/build-inline.mjs
8
+ --no-branding now drops the code rather than only the config object.
9
+
10
+ LOAD ORDER: before ck-ui.js. Registration has to happen before the first
11
+ mount(), and a page that calls ConsentKit.init() straight after ck-ui.js
12
+ mounts during the ck:init dispatch — earlier than any later-loading file
13
+ could register. Loading before ck-ui.js is unconditionally safe.
14
+
15
+ <script src="ck-core.js"></script>
16
+ <script src="ck-locales.js"></script>
17
+ <script src="ck-ui-branding.js"></script> <!-- optional -->
18
+ <script src="ck-ui.js"></script>
19
+
20
+ The contract with ck-ui.js is the object published on
21
+ window.ConsentKit._uiExtensions.branding, below. ck-ui.js owns the DOM
22
+ helpers and the localised strings and passes them in per call as `host`
23
+ ({ el, str, T }), so the dictionary, STR_KEYS and the en fallback stay in one
24
+ place and this file never reaches into ck-ui's closure.
25
+
26
+ Copyright (c) 2026 E-COM CONSULT PLUS. MIT License — see LICENSE. */
27
+ (function () {
28
+ 'use strict';
29
+
30
+ /* Restraint is the design rule here, not a matter of taste.
31
+
32
+ A consent banner is shown to every visitor of the site that installs it,
33
+ and it asks them a legal question. An agency logo, agency colours and an
34
+ attribution line all at once make it read as the agency's dialogue rather
35
+ than the site's own — visitors trust it less, and the banner competes with
36
+ the page it sits on.
37
+
38
+ So: everything in `branding` is off unless asked for, and the recommended
39
+ shape is one small logo (16–20px) OR one attribution line — with
40
+ theme.accent left matching the HOST SITE, never the agency's colour.
41
+ Nothing here may outweigh the consent buttons. */
42
+
43
+ /* SVG sanitiser.
44
+
45
+ branding.logo may be a raw SVG string coming from a server-rendered config
46
+ or a WordPress admin field. That is untrusted input, so it never reaches
47
+ innerHTML: `<svg onload=...>` executes on insertion, and so do SMIL
48
+ `<animate onbegin=...>` and `<foreignObject><img onerror=...>`.
49
+
50
+ Approach chosen: parse inert, then REBUILD rather than strip-and-adopt.
51
+ DOMParser with 'image/svg+xml' yields a detached, non-live document where
52
+ nothing runs. We then walk that tree and construct a brand-new tree with
53
+ createElementNS, copying across only allowlisted tags and attributes.
54
+
55
+ Rebuilding is what makes this safe rather than merely careful. The rejected
56
+ alternative — importNode/appendChild the parsed tree after deleting bad
57
+ attributes — arms every inline handler at the moment of adoption, so a
58
+ single missed attribute name is live code. Here an attribute we do not
59
+ recognise is simply never written, so the failure mode is a missing
60
+ decoration, not script execution. Allowlists (closed) beat blocklists
61
+ (open-ended) for the same reason.
62
+
63
+ Deliberately excluded, each for a concrete reason:
64
+ script - obvious
65
+ foreignObject - escape hatch back into full HTML
66
+ use, image - can reference/fetch external documents
67
+ a - javascript: navigation inside the logo
68
+ style - CSS escapes, and it would leak out of the
69
+ logo into our own shadow-root styling
70
+ animate/set/animateTransform - SMIL takes an attributeName and can drive
71
+ arbitrary attributes, plus on* timing events
72
+
73
+ Anything unexpected bails to null (no logo) rather than partially rendering. */
74
+
75
+ /* Local copy of ck-ui's str(): this file is loaded before ck-ui.js and must
76
+ not reach into its closure. Same contract — trimmed string or null. */
77
+ function str(v) {
78
+ return (typeof v === 'string' && v.trim()) ? v.trim() : null;
79
+ }
80
+
81
+ var SVG_NS = 'http://www.w3.org/2000/svg';
82
+
83
+ var SVG_TAGS = {
84
+ svg: 1, g: 1, path: 1, circle: 1, ellipse: 1, rect: 1, line: 1,
85
+ polyline: 1, polygon: 1, defs: 1, title: 1, desc: 1,
86
+ lineargradient: 1, radialgradient: 1, stop: 1, clippath: 1, mask: 1
87
+ };
88
+
89
+ // Presentation/geometry only. No href/xlink:href in any form, no on* events.
90
+ var SVG_ATTRS = {
91
+ viewbox: 'viewBox', preserveaspectratio: 'preserveAspectRatio',
92
+ xmlns: 'xmlns', version: 'version',
93
+ d: 'd', fill: 'fill', 'fill-rule': 'fill-rule', 'fill-opacity': 'fill-opacity',
94
+ 'clip-rule': 'clip-rule', 'clip-path': 'clip-path', mask: 'mask',
95
+ stroke: 'stroke', 'stroke-width': 'stroke-width', 'stroke-linecap': 'stroke-linecap',
96
+ 'stroke-linejoin': 'stroke-linejoin', 'stroke-dasharray': 'stroke-dasharray',
97
+ 'stroke-dashoffset': 'stroke-dashoffset', 'stroke-opacity': 'stroke-opacity',
98
+ 'stroke-miterlimit': 'stroke-miterlimit',
99
+ opacity: 'opacity', transform: 'transform',
100
+ x: 'x', y: 'y', x1: 'x1', y1: 'y1', x2: 'x2', y2: 'y2',
101
+ cx: 'cx', cy: 'cy', r: 'r', rx: 'rx', ry: 'ry',
102
+ width: 'width', height: 'height', points: 'points',
103
+ offset: 'offset', 'stop-color': 'stop-color', 'stop-opacity': 'stop-opacity',
104
+ gradientunits: 'gradientUnits', gradienttransform: 'gradientTransform',
105
+ spreadmethod: 'spreadMethod', clippathunits: 'clipPathUnits',
106
+ maskunits: 'maskUnits', maskcontentunits: 'maskContentUnits',
107
+ id: 'id', 'class': 'class'
108
+ };
109
+
110
+ /* Every sanitised logo gets a unique id namespace.
111
+
112
+ buildBrandLogo() runs twice per mount (banner + panel head), and doubles
113
+ again when logoDark is set — so one shadow root can hold four copies of the
114
+ same asset. A gradient/clipPath/mask id like "g" would then appear four
115
+ times, and url(#g) resolves to the FIRST match in the tree: the dark logo
116
+ would silently paint with the light logo's gradient stops. Prefixing every
117
+ id per instance, and rewriting the url(#…) references in the same pass,
118
+ keeps each copy self-contained. */
119
+ var svgSeq = 0;
120
+
121
+ // url(#localRef) and plain values only — no url(http…), no javascript:.
122
+ // `prefix` namespaces id definitions and their url(#…) references together.
123
+ function safeAttrValue(name, value, prefix) {
124
+ var v = String(value == null ? '' : value);
125
+ // Strip nothing; reject outright. Control chars are how javascript: is hidden.
126
+ var probe = v.replace(/[\u0000-\u0020\u007f-\u00a0]/g, '').toLowerCase();
127
+ if (probe.indexOf('javascript:') !== -1) return null;
128
+ if (probe.indexOf('data:text') !== -1) return null;
129
+ if (probe.indexOf('&#') !== -1) return null;
130
+ // Any url() must be a same-document fragment reference.
131
+ if (probe.indexOf('url(') !== -1 && !/^url\(#[a-z0-9_.:-]+\)$/i.test(probe)) return null;
132
+ if (name === 'id') {
133
+ if (!/^[a-zA-Z][\w.:-]*$/.test(v)) return null;
134
+ return prefix + v;
135
+ }
136
+ // Rewrite a reference so it points at THIS instance's namespaced definition.
137
+ var m = /^url\(#([\w.:-]+)\)$/.exec(v);
138
+ if (m) return 'url(#' + prefix + m[1] + ')';
139
+ return v;
140
+ }
141
+
142
+ function rebuildSvgNode(src, out, depth, prefix) {
143
+ if (depth > 24) return false; // pathological nesting
144
+ var kids = src.childNodes;
145
+ for (var i = 0; i < kids.length; i++) {
146
+ var n = kids[i];
147
+ if (n.nodeType === 3) { // text (only inside title/desc)
148
+ var pt = out.nodeName.toLowerCase();
149
+ if (pt === 'title' || pt === 'desc') out.appendChild(document.createTextNode(n.nodeValue));
150
+ continue;
151
+ }
152
+ if (n.nodeType !== 1) continue; // drop comments, CDATA, PIs
153
+ var tag = String(n.nodeName || '').toLowerCase();
154
+ if (!Object.prototype.hasOwnProperty.call(SVG_TAGS, tag)) return false; // bail, don't skip
155
+ var fresh = document.createElementNS(SVG_NS, n.nodeName);
156
+ var attrs = n.attributes || [];
157
+ for (var a = 0; a < attrs.length; a++) {
158
+ var an = String(attrs[a].name || '').toLowerCase();
159
+ if (/^on/i.test(an)) return false; // event handler present -> reject whole logo
160
+ if (an === 'href' || an === 'xlink:href' || an.indexOf('xlink') === 0) return false;
161
+ if (!Object.prototype.hasOwnProperty.call(SVG_ATTRS, an)) continue; // unknown -> just omit
162
+ var val = safeAttrValue(an, attrs[a].value, prefix);
163
+ if (val === null) continue;
164
+ try { fresh.setAttribute(SVG_ATTRS[an], val); } catch (e) { /* ignore */ }
165
+ }
166
+ if (!rebuildSvgNode(n, fresh, depth + 1, prefix)) return false;
167
+ out.appendChild(fresh);
168
+ }
169
+ return true;
170
+ }
171
+
172
+ // Raw SVG string -> freshly built, safe <svg> element, or null.
173
+ function sanitizeSvg(markup) {
174
+ var s = str(markup);
175
+ if (!s || s.length > 512 * 1024) return null;
176
+ if (!/^\s*<svg[\s>]/i.test(s)) return null; // must be an SVG root
177
+ var doc;
178
+ try {
179
+ doc = new DOMParser().parseFromString(s, 'image/svg+xml');
180
+ } catch (e) { return null; }
181
+ if (!doc) return null;
182
+ if (doc.getElementsByTagName('parsererror').length) return null;
183
+ var srcRoot = doc.documentElement;
184
+ if (!srcRoot || String(srcRoot.nodeName).toLowerCase() !== 'svg') return null;
185
+
186
+ // Unique per sanitised instance, so four copies of one asset never collide.
187
+ var prefix = 'ck' + (++svgSeq) + '-';
188
+
189
+ var svg = document.createElementNS(SVG_NS, 'svg');
190
+ var ra = srcRoot.attributes || [];
191
+ for (var i = 0; i < ra.length; i++) {
192
+ var an = String(ra[i].name || '').toLowerCase();
193
+ if (/^on/i.test(an)) return null;
194
+ if (an.indexOf('xlink') === 0 || an === 'href') return null;
195
+ if (!Object.prototype.hasOwnProperty.call(SVG_ATTRS, an)) continue;
196
+ var val = safeAttrValue(an, ra[i].value, prefix);
197
+ if (val === null) continue;
198
+ try { svg.setAttribute(SVG_ATTRS[an], val); } catch (e) {}
199
+ }
200
+ if (!rebuildSvgNode(srcRoot, svg, 0, prefix)) return null;
201
+ return svg;
202
+ }
203
+
204
+ /* Image-source logos.
205
+ Only http(s) and image data: URIs. data:text/html is a navigation/XSS
206
+ vector via <img>-adjacent contexts, and any other scheme is rejected.
207
+
208
+ NOTE FOR INTEGRATORS: an https:// logo is an external network request that
209
+ fires BEFORE the visitor has consented to anything. It leaks IP, User-Agent
210
+ and Referer to whoever hosts the file. ConsentKit therefore recommends an
211
+ inline SVG string or a data: URI, both of which are entirely local. An
212
+ external URL still works — it is the integrator's call, made knowingly —
213
+ and we send referrerpolicy=no-referrer to reduce what leaks. */
214
+ function safeImgSrc(value) {
215
+ var v = str(value);
216
+ if (!v) return null;
217
+ if (/^https?:\/\//i.test(v)) return v;
218
+ if (/^data:image\/(svg\+xml|png|jpe?g|webp|gif|avif)[;,]/i.test(v)) return v;
219
+ return null;
220
+ }
221
+
222
+ // Default 18px and a 32px ceiling: the logo is a signature, not a header.
223
+ // Anything taller starts competing with the banner title.
224
+ function clampLogoHeight(v) {
225
+ var n = (typeof v === 'number') ? v : parseFloat(v);
226
+ if (!isFinite(n)) return 18;
227
+ if (n < 14) return 14;
228
+ if (n > 32) return 32;
229
+ return Math.round(n);
230
+ }
231
+
232
+ // Only http(s) links are made clickable; javascript:/data: never become hrefs.
233
+ function safeLinkUrl(value) {
234
+ var v = str(value);
235
+ if (!v) return null;
236
+ return /^https?:\/\//i.test(v) ? v : null;
237
+ }
238
+
239
+ function brandingCfg(cfg) {
240
+ var b = cfg && cfg.branding;
241
+ return (b && typeof b === 'object' && !Array.isArray(b)) ? b : null;
242
+ }
243
+
244
+ // One logo node (inline SVG or <img>), already sanitised. null when unusable.
245
+ function buildLogoNode(source, alt, decorative, host) {
246
+ var el = host.el, str = host.str;
247
+ if (!source) return null;
248
+ var node = null;
249
+ var s = str(source);
250
+ if (!s) return null;
251
+
252
+ if (/^\s*</.test(s)) {
253
+ node = sanitizeSvg(s); // raw markup -> rebuilt SVG
254
+ if (node) node.classList.add('ck-brand__logo');
255
+ } else {
256
+ var src = safeImgSrc(s);
257
+ if (!src) return null;
258
+ node = el('img', 'ck-brand__logo');
259
+ node.setAttribute('referrerpolicy', 'no-referrer');
260
+ node.setAttribute('decoding', 'async');
261
+ node.src = src;
262
+ node.alt = decorative ? '' : (alt || '');
263
+ }
264
+ // The SVG carries no accessible name of its own; the wrapper supplies one
265
+ // (or hides it, when a sibling already names the logo).
266
+ if (node && node.nodeName.toLowerCase() === 'svg') {
267
+ node.setAttribute('aria-hidden', 'true');
268
+ node.setAttribute('focusable', 'false');
269
+ }
270
+ return node;
271
+ }
272
+
273
+ /* Logo block for a banner/panel header.
274
+
275
+ Dark theme: branding.logoDark, when supplied, is rendered as a second node
276
+ and swapped purely in CSS. When it is absent the single main logo shows in
277
+ both themes — which is why the shipped ECOM Consult asset (wordmark
278
+ fill="white", built for dark backgrounds) belongs in logoDark, with a
279
+ dark-ink variant in logo. An <img>/data: logo cannot be recoloured by our
280
+ CSS at all, so two assets are the only route there; an inline SVG could in
281
+ principle inherit currentColor, but only if the asset is authored that way. */
282
+ function buildBrandLogo(cfg, host) {
283
+ var el = host.el, str = host.str;
284
+ var b = brandingCfg(cfg);
285
+ if (!b) return null;
286
+
287
+ var alt = str(b.logoAlt) || '';
288
+ var main = buildLogoNode(b.logo, alt, false, host);
289
+ if (!main) return null; // no valid logo -> render nothing
290
+
291
+ var dark = buildLogoNode(b.logoDark, alt, false, host);
292
+
293
+ var wrap = el('div', 'ck-brand');
294
+ if (dark) {
295
+ wrap.classList.add('ck-brand__has-dark');
296
+ main.classList.add('ck-brand__light');
297
+ dark.classList.add('ck-brand__dark');
298
+ }
299
+
300
+ var link = safeLinkUrl(b.logoUrl);
301
+ var host_ = wrap;
302
+ if (link) {
303
+ var a = el('a', 'ck-brand__link');
304
+ a.href = link;
305
+ a.target = '_blank';
306
+ a.rel = 'noopener noreferrer';
307
+ // Links are focusable by nature; the accessible name comes from logoAlt.
308
+ a.setAttribute('aria-label', alt || 'ConsentKit');
309
+ host_ = a;
310
+ wrap.appendChild(a);
311
+ }
312
+ host_.appendChild(main);
313
+ if (dark) host_.appendChild(dark);
314
+
315
+ // A non-linked logo must not be a tab stop. The <svg>/<img> is aria-hidden
316
+ // or alt="", so a visually-hidden-free text alternative is supplied here
317
+ // for the image case only when it is not already announced by the <img> alt.
318
+ if (!link && alt && main.nodeName.toLowerCase() === 'svg') {
319
+ wrap.setAttribute('role', 'img');
320
+ wrap.setAttribute('aria-label', alt);
321
+ }
322
+ return wrap;
323
+ }
324
+
325
+ // Per-mount CSS for logo height + the dark/light swap. Mirrors buildThemeCss.
326
+ function buildBrandCss(cfg) {
327
+ var b = brandingCfg(cfg);
328
+ if (!b) return '';
329
+ var h = clampLogoHeight(b.logoHeight);
330
+ var out = [':host{--ck-logo-h:' + h + 'px}'];
331
+
332
+ var theme = (cfg && cfg.theme) || {};
333
+ var mode = theme.mode;
334
+ if (mode !== 'light' && mode !== 'dark') mode = 'auto';
335
+
336
+ // Same cascade shape as buildThemeCss so the logo always agrees with the
337
+ // palette: auto mode follows prefers-color-scheme but a forced .ck-mode-light
338
+ // still wins, and .ck-mode-dark forces the dark asset outright.
339
+ function swap(prefix) {
340
+ return prefix + ' .ck-brand__has-dark .ck-brand__light{display:none}\n' +
341
+ prefix + ' .ck-brand__has-dark .ck-brand__dark{display:block}';
342
+ }
343
+ if (mode === 'auto') {
344
+ out.push('@media (prefers-color-scheme: dark){\n' +
345
+ swap(':host(:not(.ck-mode-light))') + '\n}');
346
+ }
347
+ out.push(swap(':host(.ck-mode-dark)'));
348
+ return out.join('\n');
349
+ }
350
+
351
+ /* Powered-by line. true -> localised default; object -> caller's text/url.
352
+ Rendered after the actions in DOM order and styled quiet on purpose. */
353
+ function buildPoweredBy(cfg, host) {
354
+ var el = host.el, str = host.str, T = host.T;
355
+ var b = brandingCfg(cfg);
356
+ if (!b) return null;
357
+ var pb = b.poweredBy;
358
+ if (!pb) return null; // false/undefined -> nothing
359
+
360
+ var text, url = null;
361
+ if (pb === true) {
362
+ text = T.poweredBy; // en fallback guaranteed by STR_KEYS
363
+ } else if (typeof pb === 'object' && !Array.isArray(pb)) {
364
+ text = str(pb.text) || T.poweredBy;
365
+ url = safeLinkUrl(pb.url);
366
+ } else {
367
+ return null;
368
+ }
369
+
370
+ var p = el('p', 'ck-powered');
371
+ if (url) {
372
+ var a = el('a', null, text);
373
+ a.href = url;
374
+ a.target = '_blank';
375
+ a.rel = 'noopener noreferrer';
376
+ p.appendChild(a);
377
+ } else {
378
+ p.appendChild(document.createTextNode(text));
379
+ }
380
+ return p;
381
+ }
382
+
383
+ /* ------------------------------------------------------- static stylesheet */
384
+
385
+ /* The branding rules, returned to ck-ui.js to append to its base sheet. They
386
+ live here rather than in ck-ui.js so a build without this file carries no
387
+ dead .ck-brand / .ck-foot / .ck-powered CSS either. */
388
+ function css() {
389
+ return [
390
+ /* min-width:0 so a wide logo shrinks rather than shoving the close button
391
+ off. Scoped to :has(.ck-brand) — applying it unconditionally changes the
392
+ header block's flex sizing (480px -> 518px) on unbranded panels too, which
393
+ would break byte-for-byte backward compatibility. Browsers without :has()
394
+ simply keep today's sizing; the logo is width-capped at 160px regardless,
395
+ so the close button still has room. */
396
+ '.ck-panel__head>div:first-child:has(.ck-brand){flex:1 1 auto;min-width:0}',
397
+ /* ---- branding: logo + powered-by ----
398
+ The logo sits inline with the title in a flex row. That was chosen over a
399
+ separate band above the heading because .ck-banner--bar is a single
400
+ vertically-centred flex row: a stacked logo adds a height band there and
401
+ nowhere else, so bar/box/modal would drift apart. Inline keeps one rule
402
+ for all three layouts and leaves existing margins untouched — .ck-brand
403
+ carries the whole gap, h2 keeps its own margin. */
404
+ '.ck-brand{display:flex;align-items:center;gap:10px;margin:0 0 8px}',
405
+ /* Attribution foot of the banner: logo + credit on one muted line below
406
+ the buttons. flex-basis 100% keeps it on its own row in the bar layout,
407
+ where the actions sit beside the text.
408
+
409
+ The mark is desaturated here rather than shipped as a second grey asset:
410
+ an agency logo in full brand colour reads as a second call to action
411
+ competing with the consent buttons. grayscale() flattens the hue and the
412
+ opacity lifts it off pure black, so it sits at signature weight in both
413
+ themes without the integrator preparing anything. */
414
+ '.ck-foot{display:flex;align-items:center;gap:8px;flex-wrap:wrap;',
415
+ 'margin:14px 0 0}',
416
+ '.ck-foot .ck-brand__logo,.ck-foot .ck-brand__logo svg{',
417
+ 'filter:grayscale(1);opacity:.55}',
418
+ '.ck-foot .ck-brand__link:hover .ck-brand__logo,',
419
+ '.ck-foot .ck-brand__link:focus-visible .ck-brand__logo{opacity:.8}',
420
+ '.ck-foot .ck-brand{margin:0}',
421
+ /* Beats the flex:1 1 100% the standalone .ck-powered carries (it needs a
422
+ full row of its own when there is no logo beside it). */
423
+ '.ck-foot p.ck-powered,.ck-banner .ck-foot p.ck-powered{margin:0;flex:0 1 auto}',
424
+ /* In the panel the foot shares a flex row with the action buttons, so it
425
+ claims a row of its own below them. */
426
+ '.ck-panel__foot .ck-foot{flex:1 1 100%;margin:2px 0 0}',
427
+ '.ck-brand__logo{display:block;width:auto;max-width:160px;height:var(--ck-logo-h,24px);',
428
+ 'flex:none;object-fit:contain}',
429
+ '.ck-brand__logo svg{display:block;width:auto;height:100%;max-width:160px}',
430
+ '.ck-brand a.ck-brand__link{display:inline-flex;align-items:center;text-decoration:none;flex:none}',
431
+ /* Dark-variant swap is CSS-driven, mirroring buildThemeCss()'s cascade
432
+ exactly (same three selectors, same :not(.ck-mode-light) guard). Reading
433
+ the theme in JS would desync in auto mode and would not follow a live
434
+ system theme flip. */
435
+ '.ck-brand__dark{display:none}',
436
+ '.ck-brand__has-dark .ck-brand__light{display:block}',
437
+
438
+ /* ---- powered-by ----
439
+ Deliberately quiet: muted colour, 12px, normal weight, and it comes after
440
+ the action row in DOM order. It must not compete with the consent buttons. */
441
+ /* .ck-banner p sets font-size:14px at equal specificity and appears later in
442
+ this sheet, so it would win over a bare .ck-powered. Qualifying the
443
+ selector keeps the attribution smaller than the button text (14px) without
444
+ reaching for !important. */
445
+ '.ck-powered,.ck-banner p.ck-powered{margin:12px 0 0;font-size:12px;line-height:1.4;',
446
+ 'color:var(--ck-muted);flex:1 1 100%;font-weight:400}',
447
+ '.ck-powered a{color:var(--ck-muted);text-decoration:underline}',
448
+ '.ck-panel__foot .ck-powered{margin:0;align-self:center}',
449
+
450
+ /* Narrow bar stacks into a column, so the foot — which lives at the end of
451
+ the text block for the wide side-by-side layout — would sit between the
452
+ question and the buttons answering it. Lift it out of the text block and
453
+ order it last.
454
+
455
+ Its own @media block rather than a line inside ck-ui.js's 560px query:
456
+ the rule only exists when this file does, and a build without branding
457
+ must not carry a dangling selector for an element it never renders. The
458
+ duplicate query costs ~30 bytes and keeps the two sheets separable. */
459
+ '@media (max-width:560px){',
460
+ '.ck-banner--bar .ck-foot{order:3;margin-top:14px}}'
461
+ ].join('\n');
462
+ }
463
+
464
+ /* --------------------------------------------------------------- signature */
465
+
466
+ /* Part of ck-ui.js's mount signature: branding produces DOM, not just
467
+ styling, so a config that gains a logo after the first ck:init has to
468
+ rebuild rather than restyle. Returns '-' when there is no branding config,
469
+ which is what keeps an unbranded page's signature stable. */
470
+ function brandSignature(cfg) {
471
+ var b = brandingCfg(cfg);
472
+ if (!b) return '-';
473
+ var pb = b.poweredBy;
474
+ var pbSig = (pb && typeof pb === 'object')
475
+ ? 'o:' + String(pb.text || '') + ':' + String(pb.url || '')
476
+ : String(!!pb);
477
+ // Logos are hashed by length + head so a long data: URI does not bloat the key.
478
+ function tag(v) {
479
+ var s = str(v);
480
+ return s ? (s.length + ':' + s.slice(0, 32)) : '-';
481
+ }
482
+ return [
483
+ tag(b.logo), tag(b.logoDark), String(b.logoAlt || ''),
484
+ String(clampLogoHeight(b.logoHeight)), String(b.logoUrl || ''), pbSig
485
+ ].join('~');
486
+ }
487
+
488
+ /* ------------------------------------------------------------- registration */
489
+
490
+ /* Published on the core's namespace so ck-ui.js finds it however the two
491
+ files were loaded. The core creates window.ConsentKit at parse time; when
492
+ this file somehow runs first, a bare object is created and the core merges
493
+ onto it. */
494
+ if (typeof window === 'undefined') return;
495
+
496
+ var CK = window.ConsentKit || (window.ConsentKit = {});
497
+ var ext = CK._uiExtensions || (CK._uiExtensions = {});
498
+
499
+ ext.branding = {
500
+ buildBrandLogo: buildBrandLogo,
501
+ buildPoweredBy: buildPoweredBy,
502
+ buildBrandCss: buildBrandCss,
503
+ brandSignature: brandSignature,
504
+ sanitizeSvg: sanitizeSvg,
505
+ css: css
506
+ };
507
+
508
+ // Also exposed under the documented flat name, for integrators who reach for
509
+ // the sanitiser directly rather than through the extension slot.
510
+ window.ConsentKitBranding = ext.branding;
511
+ })();