@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-ui.js CHANGED
@@ -228,13 +228,6 @@
228
228
  'border:1px solid var(--ck-line);border-radius:var(--ck-radius);overflow:hidden}',
229
229
  '.ck-panel__head{display:flex;align-items:flex-start;gap:16px;padding:22px 24px 14px;',
230
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
231
  '.ck-panel__head h2{margin:0 0 4px;font-size:18px;font-weight:600;letter-spacing:-.01em}',
239
232
  '.ck-panel__head p{margin:0;font-size:14px;color:var(--ck-muted)}',
240
233
  '.ck-x{flex:none;width:36px;height:36px;border-radius:var(--ck-radius);border:1px solid var(--ck-line);',
@@ -279,59 +272,6 @@
279
272
  'td.ck-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}',
280
273
  '.ck-empty{margin:8px 0 0;font-size:13px;color:var(--ck-muted)}',
281
274
 
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
275
  /* ---- floating button ---- */
336
276
  '.ck-fab{position:fixed;left:16px;bottom:16px;z-index:2147482999;width:48px;height:48px;',
337
277
  'border-radius:50%;border:1px solid var(--ck-line);background:var(--ck-bg);color:var(--ck-accent);',
@@ -342,15 +282,10 @@
342
282
  '.ck-banner--bar{left:8px;right:8px;bottom:8px;padding:16px}',
343
283
  '.ck-actions{width:100%}.ck-btn{min-width:0;flex:1 1 100%}',
344
284
  '.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
285
  '.ck-banner--bar{flex-direction:column;align-items:stretch}',
350
286
  '.ck-banner--bar .ck-banner__body{display:contents}',
351
287
  '.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}}',
288
+ '.ck-banner--bar .ck-actions{order:2}}',
354
289
 
355
290
  '@media (prefers-reduced-motion: no-preference){',
356
291
  '.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}}'
@@ -376,350 +311,55 @@
376
311
  return (typeof window !== 'undefined' && window.ConsentKit) || null;
377
312
  }
378
313
 
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
- }
314
+ /* ------------------------------------------------- branding (optional file) */
566
315
 
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
- }
316
+ /* Branding lives in src/ck-ui-branding.js and is OPTIONAL. Everything below
317
+ asks for the extension and does nothing when it is absent, so a build
318
+ without that file draws no logo, no attribution line and no branding CSS —
319
+ and errors nowhere. See that file's header for the load-order contract.
576
320
 
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;
321
+ The extension is handed `host` on every DOM-producing call: ck-ui owns el()
322
+ and the localised strings (T is reassigned per mount), so passing them in
323
+ keeps one dictionary and one set of helpers rather than two copies. */
324
+ function brandingExt() {
325
+ var ck = api();
326
+ var ext = ck && ck._uiExtensions;
327
+ var b = ext && ext.branding;
328
+ return (b && typeof b === 'object') ? b : null;
587
329
  }
588
330
 
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;
331
+ function extHost() {
332
+ return { el: el, str: str, T: T };
615
333
  }
616
334
 
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
335
  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
- }
336
+ var b = brandingExt();
337
+ return (b && typeof b.buildBrandLogo === 'function') ? b.buildBrandLogo(cfg, extHost()) : null;
338
+ }
642
339
 
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;
340
+ function buildPoweredBy(cfg) {
341
+ var b = brandingExt();
342
+ return (b && typeof b.buildPoweredBy === 'function') ? b.buildPoweredBy(cfg, extHost()) : null;
666
343
  }
667
344
 
668
- // Per-mount CSS for logo height + the dark/light swap. Mirrors buildThemeCss.
669
345
  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');
346
+ var b = brandingExt();
347
+ return (b && typeof b.buildBrandCss === 'function') ? (b.buildBrandCss(cfg) || '') : '';
692
348
  }
693
349
 
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
- }
350
+ /* Static branding rules, appended to the base sheet at mount. Empty string
351
+ when the extension is absent no dead .ck-brand CSS in the build. */
352
+ function brandingCss() {
353
+ var b = brandingExt();
354
+ return (b && typeof b.css === 'function') ? ('\n' + b.css()) : '';
355
+ }
711
356
 
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;
357
+ /* Structural: branding produces DOM, so a config that gains a logo must
358
+ remount. '-' whenever there is no branding at all, which keeps an
359
+ unbranded page's signature identical to what it was before the split. */
360
+ function brandSignature(cfg) {
361
+ var b = brandingExt();
362
+ return (b && typeof b.brandSignature === 'function') ? b.brandSignature(cfg) : '-';
723
363
  }
724
364
 
725
365
  function safeState() {
@@ -1289,25 +929,9 @@
1289
929
  // Structural inputs: a change to any of these needs a rebuild, not a restyle.
1290
930
  // Branding belongs here because it produces DOM, not just styling: mount() is
1291
931
  // 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
-
932
+ // otherwise take the applyTheme()-only path and never render it. The signature
933
+ // itself is computed by the branding extension (and is '-' without it), so an
934
+ // unbranded page's signature is byte-identical to what it was before the split.
1311
935
  function signature(cfg) {
1312
936
  var c = cfg || {};
1313
937
  var lay = resolveLayout(c);
@@ -1347,7 +971,10 @@
1347
971
  root.innerHTML = '';
1348
972
 
1349
973
  var style = document.createElement('style');
1350
- style.textContent = CSS;
974
+ // Base sheet plus the branding rules, when the branding file is loaded.
975
+ // Without it brandingCss() is '' and the shadow root carries no .ck-brand,
976
+ // .ck-foot or .ck-powered rules at all.
977
+ style.textContent = CSS + brandingCss();
1351
978
  root.appendChild(style);
1352
979
 
1353
980
  switches = {};