@ecomconsult/consentkit 0.5.13 → 0.5.15

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
@@ -5,6 +5,18 @@
5
5
  (function () {
6
6
  'use strict';
7
7
 
8
+ /* SPEC §1.9 — the second copy of ck.js on the page draws nothing.
9
+ Two snippets each load the whole bundle, so this file runs twice. Every
10
+ copy is its own IIFE with its own `mounted` flag, so a second copy would
11
+ register a second set of ck:* listeners and mount a second banner into the
12
+ same #ck-root — the first copy's, which it cannot see. ck-core.js stands
13
+ down for the same reason; this is the UI half of it.
14
+ SSR-safe: with no window we behave exactly as before and fall through. The
15
+ flag is claimed further down, after the `typeof document` guard, so a copy
16
+ that only ever published the pure `_contrast` helpers into a DOM-less
17
+ context does not lock out a later copy that could actually render. */
18
+ if (typeof window !== 'undefined' && window.__ckUiLoaded) { return; }
19
+
8
20
  var OPT_IN = ['functional', 'analytics', 'marketing'];
9
21
  var ALL_CATS = ['necessary'].concat(OPT_IN);
10
22
 
@@ -21,6 +33,15 @@
21
33
  bannerLabel: 'Cookie consent',
22
34
  panelTitle: 'Cookie settings',
23
35
  panelIntro: 'Choose what to allow. By default only the necessary ones are on.',
36
+ /* SPEC V1.16 §1.2 — the «Additional information» card at the foot of the
37
+ panel: who the operator is, where to write, how long an answer takes,
38
+ where to complain. The HEADING has a real translation in every locale;
39
+ the BODY is deliberately empty in every dictionary, because the block
40
+ renders only when the site actually supplies text. A default sentence
41
+ here would put an empty operator card on every site that never
42
+ configured one. */
43
+ extraTitle: 'Additional information',
44
+ extraText: '',
24
45
  save: 'Save choices',
25
46
  close: 'Close',
26
47
  alwaysOn: 'always on',
@@ -89,6 +110,10 @@
89
110
  bannerLabel: 'Согласие на cookie',
90
111
  panelTitle: 'Настройки cookie',
91
112
  panelIntro: 'Выберите, что разрешить. По умолчанию включено только необходимое.',
113
+ // SPEC V1.16 §1.2. Заголовок переведён везде, тело пустое везде — см.
114
+ // комментарий у en выше.
115
+ extraTitle: 'Дополнительно',
116
+ extraText: '',
92
117
  save: 'Сохранить выбор',
93
118
  close: 'Закрыть',
94
119
  alwaysOn: 'всегда активны',
@@ -157,7 +182,16 @@
157
182
  // group's own one. Both are plain strings, so they belong here: a key left
158
183
  // out of this list is `undefined` for all 32 external locales and renders
159
184
  // the literal word "undefined" on the card.
160
- 'svcDetails', 'svcListLabel'
185
+ 'svcDetails', 'svcListLabel',
186
+ /* SPEC V1.16 §1.2 — the «Дополнительно» card. Both belong here for the
187
+ usual reason (a DICT key missing from this list is `undefined` for all 32
188
+ external locales), but they behave differently on purpose:
189
+
190
+ extraTitle — translated everywhere, so every locale has a heading.
191
+ extraText — '' in every dictionary. The merge line below keeps '' when
192
+ the locale supplies nothing, and the panel treats '' as
193
+ «no block». Only config.texts.<lang>.extraText fills it. */
194
+ 'extraTitle', 'extraText'
161
195
  ];
162
196
 
163
197
  // Plural-form keys, filled separately from STR_KEYS (see above).
@@ -248,13 +282,74 @@
248
282
  return list[idx].replace('{n}', String(n));
249
283
  }
250
284
 
251
- // Deep two-level fill from en: a partial locale must never yield undefined,
252
- // which would render the literal string "undefined".
253
- function buildStrings(lang, table) {
285
+ /* SPEC V1.16 §1.1 per-language dictionary overrides from the CONFIG.
286
+
287
+ `config.texts` is a mixed bag: it has carried `policyUrl`, `detailsAction`,
288
+ `declarationUrl` and `cabinetUrl` since 0.5.0, and 0.5.15 adds `links`. So a
289
+ key is a language dictionary only when it LOOKS like a language tag — that
290
+ regex is the whole separation, and it is why no future scalar setting under
291
+ `texts` can ever be mistaken for one (none of them is two letters).
292
+
293
+ Own properties only, and the value must be a plain object: `texts` is
294
+ author-supplied JSON that may have been merged over a prototype, and
295
+ reading an inherited key would hand the merge below a function. */
296
+ var LANG_KEY_RE = /^[a-z]{2}(-[a-z]{2})?$/;
297
+
298
+ function configTexts(cfg, code) {
299
+ var texts = (cfg && cfg.texts && typeof cfg.texts === 'object' && !Array.isArray(cfg.texts))
300
+ ? cfg.texts : null;
301
+ if (!texts) return null;
302
+ var k = String(code || '').toLowerCase();
303
+ if (!LANG_KEY_RE.test(k)) return null;
304
+ if (!Object.prototype.hasOwnProperty.call(texts, k)) return null;
305
+ var v = texts[k];
306
+ return (v && typeof v === 'object' && !Array.isArray(v)) ? v : null;
307
+ }
308
+
309
+ /* The keys an override may reach. A WHITELIST, not «whatever the object
310
+ carries»: an operator writing their own banner copy has no business
311
+ rewriting «Принять всё» (the button labels are what a visitor recognises
312
+ across sites) or the plural tables (an array here would break plural()).
313
+ `cat.<name>.title/desc` is handled separately below, one level deeper. */
314
+ var OVERRIDE_KEYS = [
315
+ 'bannerTitle', 'bannerText', 'panelTitle', 'panelIntro', 'extraTitle', 'extraText'
316
+ ];
317
+
318
+ // A non-empty string wins; empty or missing falls through to the layer below.
319
+ // That is what makes an empty field in the cabinet mean «take the dictionary»
320
+ // rather than «render nothing».
321
+ function pickOverride(src, key) {
322
+ if (!src) return null;
323
+ if (!Object.prototype.hasOwnProperty.call(src, key)) return null;
324
+ var v = src[key];
325
+ return (typeof v === 'string' && v) ? v : null;
326
+ }
327
+
328
+ /* Deep two-level fill from en: a partial locale must never yield undefined,
329
+ which would render the literal string "undefined".
330
+
331
+ Three layers as of 0.5.15: builtin DICT <- window.__ckLocales (both already
332
+ folded into `table`) <- config.texts[lang]. `cfg` is OPTIONAL on purpose —
333
+ placeholderText() and the cabinet's own calls ask for the plain dictionary,
334
+ and an absent third argument must build exactly what 0.5.14 built. */
335
+ function buildStrings(lang, table, cfg) {
254
336
  var src = table[lang] || {};
255
337
  var base = DICT.en;
256
338
  var out = {};
257
339
  var i, c;
340
+
341
+ /* Exact code first, then the two-letter base of the RESOLVED language:
342
+ a banner that resolved to `pt-br` takes `texts['pt-br']` over
343
+ `texts['pt']`, and a site that only wrote `texts.pt` still reaches it.
344
+ Same shape as resolveLang(), so the override follows the banner. */
345
+ var code = String(lang || '').toLowerCase();
346
+ var ov = configTexts(cfg, code);
347
+ var ovBase = (code.length > 2) ? configTexts(cfg, code.slice(0, 2)) : null;
348
+
349
+ function override(key) {
350
+ return pickOverride(ov, key) || pickOverride(ovBase, key);
351
+ }
352
+
258
353
  for (i = 0; i < STR_KEYS.length; i++) {
259
354
  var k = STR_KEYS[i];
260
355
  out[k] = (typeof src[k] === 'string' && src[k]) ? src[k] : base[k];
@@ -278,9 +373,309 @@
278
373
  desc: (typeof e.desc === 'string' && e.desc) ? e.desc : base.cat[c].desc
279
374
  };
280
375
  }
376
+
377
+ /* SPEC V1.16 §1.1 — the config layer, applied LAST so it wins over both the
378
+ builtin dictionary and __ckLocales. Only the whitelisted keys, and only
379
+ non-empty strings: an override that is absent, empty or not a string
380
+ leaves whatever the two lower layers produced.
381
+
382
+ Nothing here is ever treated as HTML. These strings go through el()'s
383
+ textContent and (for extraText) renderRich(), which builds nodes with
384
+ createElement/createTextNode — an author who writes «<b>» sees the
385
+ characters «<b>», which is the contract the server's validator mirrors. */
386
+ if (ov || ovBase) {
387
+ for (i = 0; i < OVERRIDE_KEYS.length; i++) {
388
+ var ok = OVERRIDE_KEYS[i];
389
+ var hit = override(ok);
390
+ if (hit) out[ok] = hit;
391
+ }
392
+ // cat.<name>.title / cat.<name>.desc, one level deeper. Read from a
393
+ // `cat` object on the override, with the same exact-then-base chain.
394
+ var ovCat = (ov && ov.cat && typeof ov.cat === 'object') ? ov.cat : null;
395
+ var ovBaseCat = (ovBase && ovBase.cat && typeof ovBase.cat === 'object') ? ovBase.cat : null;
396
+ for (i = 0; i < ALL_CATS.length; i++) {
397
+ c = ALL_CATS[i];
398
+ var oe = (ovCat && ovCat[c] && typeof ovCat[c] === 'object') ? ovCat[c] : null;
399
+ var obe = (ovBaseCat && ovBaseCat[c] && typeof ovBaseCat[c] === 'object') ? ovBaseCat[c] : null;
400
+ var t = pickOverride(oe, 'title') || pickOverride(obe, 'title');
401
+ var d = pickOverride(oe, 'desc') || pickOverride(obe, 'desc');
402
+ if (t) out.cat[c].title = t;
403
+ if (d) out.cat[c].desc = d;
404
+ }
405
+ }
406
+ return out;
407
+ }
408
+
409
+ /* ------------------------------------------ rich text (SPEC V1.16 §1.2) */
410
+
411
+ /* The tiny markup subset the «Дополнительно» block accepts. It exists because
412
+ an operator block is genuinely structured text — two or three paragraphs, a
413
+ bold e-mail address, a link to the supervisory authority — and because the
414
+ alternative (accepting HTML) would mean sanitising author HTML in the
415
+ browser, which is a class of bug we are not going to own.
416
+
417
+ THE RULES, in the order they are applied. The server's validator mirrors
418
+ this exactly, so the order is part of the contract, not an implementation
419
+ detail:
420
+
421
+ 1. A blank line (two newlines, any surrounding spaces) splits paragraphs.
422
+ Each paragraph becomes one <p>.
423
+ 2. Inside a paragraph, a single newline becomes <br>.
424
+ 3. LINKS ARE TOKENISED FIRST, before anything else looks at the text:
425
+ [label](URL) with URL http(s):// or mailto:
426
+ a bare https?:// URL
427
+ a bare e-mail address
428
+ Link-first is not a preference — it is what stops the bare-URL rule
429
+ from eating the address inside «[Центр](https://datepersonale.md)»
430
+ and stops the bare-e-mail rule from firing inside a mailto: label.
431
+ 4. `**bold**` is applied only to the TEXT RUNS BETWEEN links. So bold does
432
+ not nest inside a link label and a link does not appear inside bold —
433
+ one pass, no nesting, nothing recursive to get wrong.
434
+ 5. Any other URL scheme in [label](…) — javascript:, data:, file: — is
435
+ NOT a link. The WHOLE literal «[label](javascript:…)» is rendered as
436
+ plain text: visibly wrong to whoever wrote it, rather than silently
437
+ dropping the address and leaving a label that looks like a link.
438
+ 6. There is no HTML. «<b>» is five characters of text, always.
439
+
440
+ Returns an ARRAY of nodes, so the caller decides where they go. Pure apart
441
+ from document.createElement, which is what makes «no innerHTML» mechanical
442
+ rather than a promise. */
443
+
444
+ // [label](url) — the label may not contain brackets, the url may not contain
445
+ // whitespace or a closing paren. Deliberately strict: an address that needs
446
+ // an escaped paren is an address that belongs in a plain bare-URL run.
447
+ var MD_LINK_RE = /\[([^\]\[]+)\]\(([^()\s]+)\)/;
448
+ var BARE_URL_RE = /https?:\/\/[^\s<>()\[\]"']+[^\s<>()\[\]"'.,;:!?]/;
449
+ // Simple on purpose: an address that this misses renders as plain text, which
450
+ // is a worse link, not a security hole. Anything clever here is a false
451
+ // positive waiting to turn a sentence into a mailto:.
452
+ var BARE_MAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+/;
453
+
454
+ // http(s) for an outbound link, mailto: for an address. Everything else is
455
+ // refused — a javascript: or data: URL in a control the visitor is invited to
456
+ // click is the XSS vector resolveDetails() already refuses for policyUrl.
457
+ function richHref(url) {
458
+ var u = str(url);
459
+ if (!u) return null;
460
+ if (/^https?:\/\//i.test(u)) return u;
461
+ if (/^mailto:[^\s]+@[^\s]+$/i.test(u)) return u;
462
+ return null;
463
+ }
464
+
465
+ function richLink(label, href) {
466
+ var a = el('a', 'ck-rich__link', label);
467
+ a.href = href;
468
+ a.target = '_blank';
469
+ a.rel = 'noopener';
470
+ return a;
471
+ }
472
+
473
+ /* `**bold**` over a plain run. Non-greedy and never across a newline, so an
474
+ unclosed «**» is two literal asterisks rather than a bold tail that
475
+ swallows the rest of the paragraph. */
476
+ var BOLD_RE = /\*\*([^*\n]+)\*\*/;
477
+
478
+ /* PASS 3 — bare addresses inside one run of plain text. Runs LAST, on text
479
+ that no longer contains any [label](url), so it cannot reach inside one. */
480
+ function richBare(text, out) {
481
+ var rest = String(text);
482
+ while (rest) {
483
+ var url = BARE_URL_RE.exec(rest);
484
+ var mail = BARE_MAIL_RE.exec(rest);
485
+ // Whichever address starts first. A URL wins a tie: «https://a@b.md» is
486
+ // an address with a userinfo part, not an e-mail sitting after a scheme.
487
+ var m = null, isMail = false;
488
+ if (url) { m = url; }
489
+ if (mail && (!m || mail.index < m.index)) { m = mail; isMail = true; }
490
+ if (!m) { out.push(document.createTextNode(rest)); return; }
491
+ if (m.index > 0) out.push(document.createTextNode(rest.slice(0, m.index)));
492
+ out.push(richLink(m[0], isMail ? ('mailto:' + m[0]) : m[0]));
493
+ rest = rest.slice(m.index + m[0].length);
494
+ }
495
+ }
496
+
497
+ /* PASS 2 — `**bold**` over one run of text, then PASS 3 inside each half.
498
+
499
+ Bold has to come BEFORE the bare-address rules, not after: «**mail@x.md**»
500
+ is one bold run containing an address, and a pass that linked the address
501
+ first would split the run in three and leave BOLD_RE looking at «**» and
502
+ «**» separately — the asterisks would survive on screen. That is the whole
503
+ reason these are three ordered passes rather than one left-to-right walk. */
504
+ function richBold(text, out) {
505
+ var rest = String(text);
506
+ var m;
507
+ while ((m = BOLD_RE.exec(rest))) {
508
+ if (m.index > 0) richBare(rest.slice(0, m.index), out);
509
+ var strong = el('strong', null);
510
+ var inner = [];
511
+ richBare(m[1], inner); // an address inside bold is still a link
512
+ for (var i = 0; i < inner.length; i++) strong.appendChild(inner[i]);
513
+ out.push(strong);
514
+ rest = rest.slice(m.index + m[0].length);
515
+ }
516
+ if (rest) richBare(rest, out);
517
+ }
518
+
519
+ /* PASS 1 — one line of a paragraph -> nodes.
520
+
521
+ `[label](url)` is tokenised first and its pieces never reach passes 2 and 3:
522
+ that is what stops the bare-URL rule from eating the address inside the
523
+ parens, stops the bare-e-mail rule from firing inside a mailto: label, and
524
+ leaves a label's own «**» literal (rule 4 of the contract). The text
525
+ BETWEEN markdown links goes through richBold(), which then runs richBare()
526
+ inside and outside each bold span. */
527
+ function richLine(line, out) {
528
+ var rest = String(line);
529
+ var m;
530
+ while ((m = MD_LINK_RE.exec(rest))) {
531
+ if (m.index > 0) richBold(rest.slice(0, m.index), out);
532
+ var href = richHref(m[2]);
533
+ // Rule 5: a refused scheme is the whole literal, verbatim and unbolded,
534
+ // so «[x](javascript:alert(1))» reads as exactly what was written.
535
+ if (href) out.push(richLink(m[1], href));
536
+ else out.push(document.createTextNode(m[0]));
537
+ rest = rest.slice(m.index + m[0].length);
538
+ }
539
+ if (rest) richBold(rest, out);
540
+ }
541
+
542
+ /* text -> array of <p> nodes. Empty (or non-string) input gives an empty
543
+ array, which is what lets the caller say «no text, no block» by asking for
544
+ the length rather than by re-testing the string. */
545
+ function renderRich(text) {
546
+ var s = (typeof text === 'string') ? text : '';
547
+ if (!s.trim()) return [];
548
+ // \r\n and \r normalised first: a value pasted from Windows or copied out of
549
+ // a textarea must split into the same paragraphs as one typed here.
550
+ var paras = s.replace(/\r\n?/g, '\n').split(/\n[ \t]*\n+/);
551
+ var out = [];
552
+ for (var i = 0; i < paras.length; i++) {
553
+ var para = paras[i].replace(/^\n+|\n+$/g, '');
554
+ if (!para.trim()) continue;
555
+ var p = el('p', 'ck-rich__p');
556
+ var lines = para.split('\n');
557
+ for (var j = 0; j < lines.length; j++) {
558
+ if (j > 0) p.appendChild(el('br')); // rule 2: a single newline
559
+ var nodes = [];
560
+ richLine(lines[j], nodes);
561
+ for (var k = 0; k < nodes.length; k++) p.appendChild(nodes[k]);
562
+ }
563
+ out.push(p);
564
+ }
565
+ return out;
566
+ }
567
+
568
+ /* ------------------------------------------ banner links (SPEC V1.16 §1.3) */
569
+
570
+ /* `texts.links` — up to three of the operator's own addresses, rendered under
571
+ the banner buttons and at the foot of the settings panel. The obvious pair
572
+ is «Политика конфиденциальности» and «Политика cookie», which a Moldovan
573
+ site is required to publish and which have nowhere else to go on the card.
574
+
575
+ Deliberately NOT tangled with `detailsAction`: «Подробнее» keeps doing
576
+ exactly what it did in 0.5.14, because a site that gains a link row must
577
+ not silently lose the link it already had.
578
+
579
+ A row is skipped, never faked, when its address is not http(s) or when no
580
+ label resolves for the banner's language — a link with no words on it is
581
+ not a link. The cap is applied to the SURVIVORS: one malformed row must not
582
+ cost a good one its place. */
583
+ function linkLabel(labels, lang) {
584
+ if (!labels || typeof labels !== 'object' || Array.isArray(labels)) return null;
585
+ var code = (typeof lang === 'string') ? lang.toLowerCase() : '';
586
+ // Own properties only, exactly as branding's pickPoweredByText does it and
587
+ // for the same reason: `labels` is author-supplied JSON, and an inherited
588
+ // key would hand str() a function instead of a missing line.
589
+ function own(k) {
590
+ return (k && Object.prototype.hasOwnProperty.call(labels, k)) ? str(labels[k]) : null;
591
+ }
592
+ var hit = own(code);
593
+ if (!hit && code.length > 2) hit = own(code.slice(0, 2)); // ru-RU -> ru
594
+ return hit || own('en');
595
+ }
596
+
597
+ var MAX_LINKS = 3;
598
+
599
+ function resolveLinks(cfg, lang) {
600
+ var texts = (cfg && cfg.texts && typeof cfg.texts === 'object') ? cfg.texts : null;
601
+ var list = texts && texts.links;
602
+ if (!list || !Array.isArray(list)) return [];
603
+ var out = [];
604
+ for (var i = 0; i < list.length && out.length < MAX_LINKS; i++) {
605
+ var row = list[i];
606
+ if (!row || typeof row !== 'object') continue;
607
+ var url = str(row.url);
608
+ if (!url || !/^https?:\/\//i.test(url)) continue;
609
+ var label = linkLabel(row.label, lang);
610
+ if (!label) continue;
611
+ out.push({ id: str(row.id) || ('link' + i), url: url, label: label });
612
+ }
281
613
  return out;
282
614
  }
283
615
 
616
+ /* SPEC V1.16 §1.3, last sentence — the one place where the link row DOES
617
+ touch «Подробнее»: when the address it would send the visitor to is
618
+ already one of the rows under the buttons, the banner shows the same
619
+ address twice. The second copy is not more information, it is noise, so
620
+ the in-text link is dropped and the banner renders as `detailsAction:
621
+ 'hide'` for that render — the link row keeps the address, with a label the
622
+ operator wrote, which is the better of the two.
623
+
624
+ Only 'policy' and 'declaration' can duplicate anything: 'settings' opens
625
+ the panel and has no address to collide with, and 'hide' is already gone.
626
+
627
+ Comparison is deliberately narrow. Trimming and a trailing slash are
628
+ typography, not identity — `https://shop.md/cookies` and
629
+ `…/cookies/` are the same page — and so is the case of the HOST, which
630
+ DNS does not distinguish. The PATH is left alone: `/Cookies` and
631
+ `/cookies` are genuinely two addresses on a case-sensitive server, and
632
+ suppressing a real «Подробнее» link over that guess is the worse error. */
633
+ function normHref(v) {
634
+ var s = str(v);
635
+ if (!s) return null;
636
+ var m = /^(https?:\/\/[^\/?#]*)([\s\S]*)$/i.exec(s);
637
+ if (!m) return null;
638
+ return (m[1].toLowerCase() + m[2]).replace(/\/$/, '');
639
+ }
640
+
641
+ function detailsDuplicatesLink(details, links) {
642
+ if (!details) return false;
643
+ if (details.kind !== 'policy' && details.kind !== 'declaration') return false;
644
+ var href = normHref(details.href);
645
+ if (!href || !links || !Array.isArray(links)) return false;
646
+ for (var i = 0; i < links.length; i++) {
647
+ if (links[i] && normHref(links[i].url) === href) return true;
648
+ }
649
+ return false;
650
+ }
651
+
652
+ /* Which of the three shapes the banner will actually draw, duplicate rule
653
+ applied — so buildBanner() and signature() cannot disagree about it.
654
+ `lang` is the code the banner resolved to; signature() passes the config's
655
+ own so both sides of a comparison are read in the same language. */
656
+ function detailsKind(cfg, lang) {
657
+ var det = resolveDetails(cfg);
658
+ var code = (typeof lang === 'string' && lang) ? lang :
659
+ resolveLang(cfg && cfg.language, localeTable());
660
+ return detailsDuplicatesLink(det, resolveLinks(cfg, code)) ? 'hide' : det.kind;
661
+ }
662
+
663
+ // The row itself, or null when there is nothing to draw — so both call sites
664
+ // (banner and panel) are one `if` rather than two.
665
+ function buildLinksRow(cfg, cls) {
666
+ var links = resolveLinks(cfg, LANG);
667
+ if (!links.length) return null;
668
+ var row = el('div', 'ck-links' + (cls ? ' ' + cls : ''));
669
+ for (var i = 0; i < links.length; i++) {
670
+ var a = el('a', 'ck-links__a', links[i].label);
671
+ a.href = links[i].url;
672
+ a.target = '_blank';
673
+ a.rel = 'noopener';
674
+ row.appendChild(a);
675
+ }
676
+ return row;
677
+ }
678
+
284
679
  /* ------------------------------------------- blocked-embed placeholder (§2) */
285
680
 
286
681
  /* The one piece of the placeholder that is worth testing without a DOM: which
@@ -525,6 +920,40 @@
525
920
  >= 4.5:1 rule as «Подробнее» — see the note on the `a{}` rule above. */
526
921
  '.ck-svc__policy{display:inline-block;margin-top:4px;font-size:12.5px;color:var(--ck-link)}',
527
922
 
923
+ /* ---- «Дополнительно» card (SPEC V1.16 §1.2) ---- */
924
+ /* A card, not another category row: it carries no switch and answers a
925
+ different question — who the operator is and how to reach them. So it
926
+ gets the soft fill and the rounded border the cookie table wears, which
927
+ reads as «a panel of reference text» rather than «a fifth thing you can
928
+ turn off».
929
+
930
+ It is appended AFTER the categories, which means the last .ck-cat is no
931
+ longer `:last-child` and keeps its bottom rule — that is wanted here: the
932
+ rule is what separates the last category from this block. The rule below
933
+ restores the old look for the category above it only in the sense that
934
+ the separator now belongs to the boundary rather than to the group. */
935
+ '.ck-extra{margin:16px 0 4px;padding:14px 16px;background:var(--ck-soft);',
936
+ 'border:1px solid var(--ck-line);border-radius:var(--ck-radius-card)}',
937
+ '.ck-extra__title{margin:0 0 6px;font-size:15px;font-weight:600}',
938
+ /* Same size and colour as .ck-cat__desc: this is secondary reference text,
939
+ and it is measured for AA against the card by the same rule. */
940
+ '.ck-extra__body p{margin:0 0 8px;font-size:13.5px;color:var(--ck-muted);line-height:1.5}',
941
+ '.ck-extra__body p:last-child{margin-bottom:0}',
942
+ /* --ck-link, not --ck-accent — accent-coloured TEXT answers to 4.5:1
943
+ against the card; see the note on the `a{}` rule above. */
944
+ '.ck-extra__body a{color:var(--ck-link)}',
945
+
946
+ /* ---- link row under the buttons (SPEC V1.16 §1.3) ---- */
947
+ /* One row, wrapping. `width:100%` matters in the bar layout, whose banner is
948
+ a flex row: without it the row would try to sit BESIDE the buttons on a
949
+ wide screen instead of under them. */
950
+ '.ck-links{display:flex;gap:14px;flex-wrap:wrap;width:100%;margin-top:12px;font-size:13px}',
951
+ '.ck-links__a{color:var(--ck-link)}',
952
+ /* The panel foot is a flex row of buttons that each take `flex:1 1 150px`;
953
+ a full-width row forced onto its own line keeps the links under the
954
+ buttons rather than wedged between two of them. */
955
+ '.ck-links--panel{flex-basis:100%;margin-top:2px}',
956
+
528
957
  /* ---- switch ---- */
529
958
  '.ck-switch{flex:none;width:46px;height:27px;padding:0;border-radius:999px;',
530
959
  'border:1px solid var(--ck-line);background:var(--ck-soft);position:relative}',
@@ -573,6 +1002,9 @@
573
1002
  '.ck-banner--bar .ck-banner__body{display:contents}',
574
1003
  '.ck-banner--bar .ck-banner__body>*{order:1}',
575
1004
  '.ck-banner--bar .ck-actions{order:2}',
1005
+ // SPEC V1.16 §1.3 — the link row is `order:3` so it stays under the buttons
1006
+ // in the bar's mobile column, where every child's position is explicit.
1007
+ '.ck-banner--bar .ck-links{order:3}',
576
1008
  /* Reference: the box's button row becomes a column under 560px. */
577
1009
  '.ck-banner--box .ck-actions{flex-direction:column}',
578
1010
  '.ck-banner--box .ck-btn{flex:1 1 auto;width:100%}}',
@@ -2076,6 +2508,14 @@
2076
2508
 
2077
2509
  var p = el('p');
2078
2510
  var det = resolveDetails(cfg);
2511
+ /* SPEC V1.16 §1.3 — «Подробнее» and one of the link rows point at the same
2512
+ page: drop the in-text copy rather than print the address twice. Asked
2513
+ through detailsKind() so the shape drawn here is the same one signature()
2514
+ watches for changes. The links are resolved for THIS render's language,
2515
+ exactly as buildLinksRow does below, so a row that no visitor of this
2516
+ banner can see (no label in their language) never silences the link
2517
+ they can. */
2518
+ if (detailsKind(cfg, LANG) === 'hide') det = { kind: 'hide', href: null };
2079
2519
  if (det.kind === 'hide') {
2080
2520
  p.appendChild(document.createTextNode(T.bannerText));
2081
2521
  } else {
@@ -2136,6 +2576,18 @@
2136
2576
  actions.appendChild(custom);
2137
2577
  b.appendChild(actions);
2138
2578
 
2579
+ /* SPEC V1.16 §1.3 — the link row, under the buttons in all three layouts.
2580
+ Appended to the BANNER rather than to the body, so it sits after the
2581
+ actions in DOM (and reading) order everywhere: box and modal stack, and
2582
+ the bar's `width:100%` on .ck-links forces it onto its own line under
2583
+ the row instead of squeezing in beside the buttons.
2584
+
2585
+ The bar's mobile media query re-orders its children explicitly (body
2586
+ contents `order:1`, actions `order:2`); the row carries `order:3` there
2587
+ so it stays below the buttons rather than jumping above them. */
2588
+ var blinks = buildLinksRow(cfg, 'ck-links--banner');
2589
+ if (blinks) b.appendChild(blinks);
2590
+
2139
2591
  // box/modal: the foot follows the buttons (see the note above).
2140
2592
  if (foot && lay.type !== 'bar') b.appendChild(foot);
2141
2593
 
@@ -2147,6 +2599,21 @@
2147
2599
  root.appendChild(b);
2148
2600
  }
2149
2601
 
2602
+ /* SPEC V1.16 §1.2 — the «Дополнительно» card, or null when the site supplied
2603
+ no text for this language. T.extraText has already been through the whole
2604
+ merge (dictionary '' <- __ckLocales <- config.texts[lang] <- [base]), so
2605
+ the only question left here is whether anything survived it. */
2606
+ function buildExtra() {
2607
+ var nodes = renderRich(T.extraText);
2608
+ if (!nodes.length) return null;
2609
+ var box = el('div', 'ck-extra');
2610
+ box.appendChild(el('h3', 'ck-extra__title', T.extraTitle));
2611
+ var bodyEl = el('div', 'ck-extra__body');
2612
+ for (var i = 0; i < nodes.length; i++) bodyEl.appendChild(nodes[i]);
2613
+ box.appendChild(bodyEl);
2614
+ return box;
2615
+ }
2616
+
2150
2617
  function buildPanel(cfg) {
2151
2618
  var scrim = el('div', 'ck-panel-scrim ck-hidden');
2152
2619
  scrim.setAttribute('aria-hidden', 'true');
@@ -2174,6 +2641,18 @@
2174
2641
  var body = el('div', 'ck-panel__body');
2175
2642
  var cats = ['necessary'].concat(activeOptIn(cfg));
2176
2643
  for (var i = 0; i < cats.length; i++) body.appendChild(buildCategory(cfg, cats[i]));
2644
+
2645
+ /* SPEC V1.16 §1.2 — «Дополнительно», after the categories and their service
2646
+ lists, before the buttons. INSIDE .ck-panel__body, which is the scrolling
2647
+ region: an operator block runs to a paragraph or three, and put between
2648
+ the body and the foot it would be clipped on a short viewport with no way
2649
+ to reach the end of it.
2650
+
2651
+ Rendered only when the resolved extraText is a non-empty string — every
2652
+ dictionary ships '', so a site that never configured one draws nothing
2653
+ and the panel is byte-for-byte what 0.5.14 drew. */
2654
+ var extra = buildExtra();
2655
+ if (extra) body.appendChild(extra);
2177
2656
  p.appendChild(body);
2178
2657
 
2179
2658
  var foot = el('div', 'ck-panel__foot');
@@ -2201,6 +2680,11 @@
2201
2680
  foot.appendChild(save);
2202
2681
  foot.appendChild(acc);
2203
2682
  foot.appendChild(rej);
2683
+ // SPEC V1.16 §1.3 — the operator's own addresses, under the panel's buttons
2684
+ // and above the attribution, so the bottom of the panel reads: what you can
2685
+ // do, then where to read more, then who made this.
2686
+ var plinks = buildLinksRow(cfg, 'ck-links--panel');
2687
+ if (plinks) foot.appendChild(plinks);
2204
2688
  // Same attribution foot as the banner: mark and credit sign the bottom,
2205
2689
  // below the action buttons, never the panel heading.
2206
2690
  var pfoot = buildPoweredBy(cfg);
@@ -2757,7 +3241,14 @@
2757
3241
  // Structural: link / button / nothing are three different DOM shapes,
2758
3242
  // and mount() is one-shot. Buttons and colours are deliberately NOT
2759
3243
  // here — they are token values and restyle in place.
2760
- resolveDetails(c).kind,
3244
+ /* SPEC V1.16 §1.3 — the EFFECTIVE kind, not the configured one: a config
3245
+ whose policyUrl grows into (or out of) one of its own link rows flips
3246
+ the shape between link and nothing while resolveDetails still answers
3247
+ 'policy'. Without the fold, a SaaS config arriving after the first
3248
+ mount would leave the duplicate on screen. The language is the config's
3249
+ own, resolved the way mount() resolves it, so the two sides of a
3250
+ comparison are always read in the same language. */
3251
+ detailsKind(c),
2761
3252
  /* SPEC V1.12 §3 — services are STRUCTURAL: each one adds a row with its
2762
3253
  own switch to the panel, and mount() is one-shot. Without this a SaaS
2763
3254
  config that arrives after the first mount (the second, idempotent
@@ -2770,10 +3261,43 @@
2770
3261
  `purpose` is text inside an existing row and does not justify tearing
2771
3262
  the panel down. */
2772
3263
  serviceSignature(c),
3264
+ /* SPEC V1.16 §1 — custom texts are STRUCTURAL for the same reason
3265
+ services are: the «Дополнительно» card and the link row are DOM nodes
3266
+ that appear and disappear, and mount() is one-shot. A SaaS config that
3267
+ arrives after the first render (the second, idempotent init()) would
3268
+ otherwise leave a panel with no operator block on a site that has one.
3269
+
3270
+ Not the texts themselves, which can be long: the LANGUAGE KEYS that
3271
+ carry overrides, whether an extra block would render, and the links'
3272
+ identity. A reworded sentence inside a block that is already on screen
3273
+ does not justify tearing the panel down mid-decision. */
3274
+ textsSignature(c),
2773
3275
  brandSignature(c)
2774
3276
  ].join('|');
2775
3277
  }
2776
3278
 
3279
+ function textsSignature(cfg) {
3280
+ var texts = (cfg && cfg.texts && typeof cfg.texts === 'object' && !Array.isArray(cfg.texts))
3281
+ ? cfg.texts : null;
3282
+ if (!texts) return '0';
3283
+ var langs = [];
3284
+ for (var k in texts) {
3285
+ if (!Object.prototype.hasOwnProperty.call(texts, k)) continue;
3286
+ if (LANG_KEY_RE.test(String(k).toLowerCase())) langs.push(String(k).toLowerCase());
3287
+ }
3288
+ langs.sort();
3289
+ var list = texts.links;
3290
+ var ids = [];
3291
+ if (Array.isArray(list)) {
3292
+ for (var i = 0; i < list.length; i++) {
3293
+ var row = list[i];
3294
+ if (row && typeof row === 'object') ids.push(String(row.id || i) + ':' + String(row.url || ''));
3295
+ }
3296
+ }
3297
+ if (!langs.length && !ids.length) return '0';
3298
+ return langs.join(',') + '/' + ids.join(',');
3299
+ }
3300
+
2777
3301
  function serviceSignature(cfg) {
2778
3302
  var list = cfg && cfg.services;
2779
3303
  if (!list || !Array.isArray(list) || !list.length) return '0';
@@ -2816,7 +3340,10 @@
2816
3340
 
2817
3341
  var table = localeTable(); // read at render time
2818
3342
  LANG = resolveLang(cfg && cfg.language, table);
2819
- T = buildStrings(LANG, table);
3343
+ // SPEC V1.16 §1.1 — the config is the third merge layer, so it must be
3344
+ // handed in here: this is the only place that knows which language the
3345
+ // banner actually resolved to, which is what the override is keyed by.
3346
+ T = buildStrings(LANG, table, cfg);
2820
3347
 
2821
3348
  host = document.getElementById('ck-root');
2822
3349
  if (!host) {
@@ -2952,6 +3479,22 @@
2952
3479
  servicePurpose: servicePurpose,
2953
3480
  groupCountLabel: groupCountLabel,
2954
3481
  serviceSignature: serviceSignature,
3482
+ /* SPEC V1.16 §1 — the pure halves of the custom-text feature, so the
3483
+ cabinet's live preview quotes the same rules the banner paints and the
3484
+ server's validator can be tested against the same contract.
3485
+
3486
+ renderRich() is the exception to «nothing here touches the DOM»: it
3487
+ BUILDS nodes, so it needs document.createElement. That is deliberate —
3488
+ the whole point of the function is that there is no innerHTML anywhere
3489
+ in it, and a version that returned a string could not promise that. It
3490
+ is still pure in every other sense: same input, same nodes, no reads of
3491
+ module state. In a context with no `document` it is simply not called. */
3492
+ renderRich: renderRich,
3493
+ resolveLinks: resolveLinks,
3494
+ linkLabel: linkLabel,
3495
+ detailsDuplicatesLink: detailsDuplicatesLink,
3496
+ detailsKind: detailsKind,
3497
+ textsSignature: textsSignature,
2955
3498
  signature: signature
2956
3499
  };
2957
3500
  // The page-font probe reads the DOM, so it is not part of the pure block —
@@ -2971,6 +3514,11 @@
2971
3514
  // this file in Node is a no-op rather than a throw (mirrors the core).
2972
3515
  if (typeof document === 'undefined') return;
2973
3516
 
3517
+ // Claim the page for this copy (SPEC §1.9, guard at the top of the file).
3518
+ // Here rather than at the top: only now does this copy take ownership of the
3519
+ // listeners and the mount, and only that ownership is worth locking.
3520
+ try { if (typeof window !== 'undefined') { window.__ckUiLoaded = true; } } catch (e) { /* noop */ }
3521
+
2974
3522
  document.addEventListener('ck:init', function (e) {
2975
3523
  var d = (e && e.detail) || {};
2976
3524
  var cfg = d.config || safeConfig();