@ecomconsult/consentkit 0.5.6 → 0.5.8

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
@@ -32,6 +32,26 @@
32
32
  colExpiry: 'Expires',
33
33
  floating: 'Cookie settings',
34
34
  poweredBy: 'Powered by ConsentKit',
35
+ // SPEC V1.12 §3 — services inside a category group.
36
+ //
37
+ // `svcCount`/`ckCount` are PLURAL FORMS, not plain strings: «1 сервис»,
38
+ // «2 сервиса», «5 сервисов». Each is an array read by plural() below, and
39
+ // each language supplies as many forms as its own grammar needs — one for
40
+ // English, three for Russian. {n} is the number.
41
+ svcCount: ['{n} service', '{n} services'],
42
+ ckCount: ['{n} cookie', '{n} cookies'],
43
+ svcPolicy: 'Privacy policy',
44
+ // The service's own cookies, listed under it rather than in the group's
45
+ // «Which cookies» table.
46
+ svcCookies: 'Cookies it sets',
47
+ // SPEC V1.10 §2 — the blocked-embed placeholder. {host} is the vendor
48
+ // label when the database knows one and the bare host otherwise; {cat} is
49
+ // the localized category title, taken from cat.<name>.title below, so the
50
+ // name in the placeholder is the name on the switch in the panel.
51
+ phText: 'Content from {host} goes here. It will load once you allow «{cat}».',
52
+ phAllow: 'Allow and show',
53
+ phSettings: 'Cookie settings',
54
+ phLabel: 'Blocked content',
35
55
  cat: {
36
56
  necessary: {
37
57
  title: 'Necessary',
@@ -72,6 +92,15 @@
72
92
  colExpiry: 'Срок',
73
93
  floating: 'Настройки cookie',
74
94
  poweredBy: 'Работает на ConsentKit',
95
+ // SPEC V1.12 §3. Три формы: 1 сервис, 2 сервиса, 5 сервисов.
96
+ svcCount: ['{n} сервис', '{n} сервиса', '{n} сервисов'],
97
+ ckCount: ['{n} cookie', '{n} cookie', '{n} cookie'],
98
+ svcPolicy: 'Политика',
99
+ svcCookies: 'Какие cookie ставит',
100
+ phText: 'Здесь содержимое от {host}. Оно загрузится после согласия на «{cat}».',
101
+ phAllow: 'Разрешить и показать',
102
+ phSettings: 'Настроить cookie',
103
+ phLabel: 'Заблокированное содержимое',
75
104
  cat: {
76
105
  necessary: {
77
106
  title: 'Необходимые',
@@ -103,9 +132,22 @@
103
132
  'bannerTitle', 'bannerText', 'more', 'acceptAll', 'rejectAll', 'customize',
104
133
  'bannerLabel', 'panelTitle', 'panelIntro', 'save', 'close', 'alwaysOn',
105
134
  'cookiesIn', 'noCookies', 'colName', 'colVendor', 'colPurpose', 'colExpiry', 'floating',
106
- 'poweredBy'
135
+ 'poweredBy',
136
+ // SPEC V1.10 §2. Present in ck-locales.js for ro only; every other external
137
+ // locale falls back to DICT.en through buildStrings(), which is what §2
138
+ // asks for («остальные языки — en»).
139
+ 'phText', 'phAllow', 'phSettings', 'phLabel',
140
+ // SPEC V1.12 §3. svcPolicy/svcCookies are plain strings and belong here;
141
+ // svcCount/ckCount are ARRAYS of plural forms and are filled by their own
142
+ // branch in buildStrings() — listing them here would be a bug, because this
143
+ // loop only copies values that are `typeof === 'string'` and would leave
144
+ // every external locale on the English plurals.
145
+ 'svcPolicy', 'svcCookies'
107
146
  ];
108
147
 
148
+ // Plural-form keys, filled separately from STR_KEYS (see above).
149
+ var PLURAL_KEYS = ['svcCount', 'ckCount'];
150
+
109
151
  // builtin(en,ru) <- window.__ckLocales, read at render time so the locales
110
152
  // file may load in any order relative to this one.
111
153
  function localeTable() {
@@ -137,6 +179,60 @@
137
179
  return 'en';
138
180
  }
139
181
 
182
+ /* SPEC V1.12 §3 — plural forms.
183
+
184
+ Validated as a WHOLE: an array of at least one non-empty string, or null.
185
+ A locale that supplies `['{n} сервис']` alone is honest — it says «this
186
+ language has one form» — and plural() below simply always picks it. */
187
+ function pluralForms(v) {
188
+ if (!v || !Array.isArray(v) || !v.length) return null;
189
+ var out = [];
190
+ for (var i = 0; i < v.length && i < 3; i++) {
191
+ if (typeof v[i] !== 'string' || !v[i]) return null;
192
+ out.push(v[i]);
193
+ }
194
+ return out;
195
+ }
196
+
197
+ /* Picks the form for `n` and substitutes it in.
198
+
199
+ Three families, chosen by the language code rather than by
200
+ Intl.PluralRules: Intl is present in every browser this client supports,
201
+ but its category NAMES ('one'|'few'|'many'|'other') vary per language, and
202
+ mapping them onto a positional array is more code and more failure modes
203
+ than the two rules that actually matter here.
204
+
205
+ ru/uk/sr/hr/… 1, 21, 31 -> [0]; 2-4, 22-24 -> [1]; 0, 5-20 -> [2]
206
+ ro 1 -> [0]; 0 and 2-19 -> [1]; 20+ -> [2] («20 de servicii»)
207
+ everything else 1 -> [0]; otherwise -> [1]
208
+
209
+ A locale with fewer forms than the rule asks for clamps to its last one, so
210
+ a single-form array can never index past its end. */
211
+ var SLAVIC_PLURAL = { ru: 1, uk: 1, sr: 1, hr: 1, cs: 1, sk: 1, pl: 1, be: 1, bs: 1 };
212
+
213
+ function pluralIndex(lang, n) {
214
+ var code = String(lang || 'en').slice(0, 2).toLowerCase();
215
+ var mod10 = n % 10, mod100 = n % 100;
216
+ if (SLAVIC_PLURAL[code]) {
217
+ if (mod10 === 1 && mod100 !== 11) return 0;
218
+ if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return 1;
219
+ return 2;
220
+ }
221
+ if (code === 'ro' || code === 'mo') {
222
+ if (n === 1) return 0;
223
+ if (n === 0 || (mod100 >= 1 && mod100 <= 19)) return 1;
224
+ return 2;
225
+ }
226
+ return n === 1 ? 0 : 1;
227
+ }
228
+
229
+ function plural(forms, n, lang) {
230
+ var list = pluralForms(forms) || ['{n}'];
231
+ var idx = pluralIndex(lang, n);
232
+ if (idx >= list.length) idx = list.length - 1;
233
+ return list[idx].replace('{n}', String(n));
234
+ }
235
+
140
236
  // Deep two-level fill from en: a partial locale must never yield undefined,
141
237
  // which would render the literal string "undefined".
142
238
  function buildStrings(lang, table) {
@@ -148,6 +244,15 @@
148
244
  var k = STR_KEYS[i];
149
245
  out[k] = (typeof src[k] === 'string' && src[k]) ? src[k] : base[k];
150
246
  }
247
+ /* SPEC V1.12 §3 — plural forms. An array of 1..3 strings, taken from the
248
+ locale only when it is a non-empty array of strings; anything else falls
249
+ back to English wholesale rather than per-slot, because a half-filled
250
+ plural table produces «2 services» inside a Russian sentence. */
251
+ for (i = 0; i < PLURAL_KEYS.length; i++) {
252
+ var pk = PLURAL_KEYS[i];
253
+ out[pk] = pluralForms(src[pk]) || base[pk].slice();
254
+ }
255
+
151
256
  out.cat = {};
152
257
  var sc = (src.cat && typeof src.cat === 'object') ? src.cat : {};
153
258
  for (i = 0; i < ALL_CATS.length; i++) {
@@ -161,6 +266,74 @@
161
266
  return out;
162
267
  }
163
268
 
269
+ /* ------------------------------------------- blocked-embed placeholder (§2) */
270
+
271
+ /* The one piece of the placeholder that is worth testing without a DOM: which
272
+ sentence a visitor reads. PURE — takes a host (or vendor label), a category
273
+ name and a language, returns the finished string.
274
+
275
+ `host` is used verbatim: the core has no vendor-label table (only the SaaS
276
+ scanner's VENDOR_DB does, server-side), so §2's «ярлык хоста; без ярлыка —
277
+ содержимое с <host>» collapses to the host here. When a label lookup is
278
+ added to the core later, this signature already accepts it — pass the label
279
+ instead of the host and nothing else changes.
280
+
281
+ The category name is resolved through the SAME buildStrings() the panel
282
+ uses, so «Маркетинг» in the placeholder is «Маркетинг» on the switch. An
283
+ unknown language falls back to en, an unknown category to marketing — the
284
+ category a strict-mode interception is filed under. */
285
+ /* SPEC V1.12 §3 — «заглушки iframe — по сервису».
286
+
287
+ `subject` names what the visitor has to agree to for this frame to appear.
288
+ Left out, it is the category, exactly as in 0.5.7. Passed, it is the
289
+ SERVICE name — which is the only honest sentence in the state this wave
290
+ introduces: analytics granted, one service refused, the frame still held.
291
+ Naming the category there would read «загрузится после согласия на
292
+ "Аналитика"» to a visitor who has already agreed to analytics. */
293
+ function placeholderText(host, category, lang, subject) {
294
+ var table = localeTable();
295
+ var T2 = buildStrings(resolveLang(lang, table), table);
296
+ var cat = (category && T2.cat[category]) ? category : 'marketing';
297
+ var name = String(host || '').trim();
298
+ var label = (typeof subject === 'string' && subject.trim())
299
+ ? subject.trim() : T2.cat[cat].title;
300
+ return T2.phText
301
+ .replace('{host}', name || T2.phLabel)
302
+ .replace('{cat}', label);
303
+ }
304
+
305
+ /* Which service, if any, is what is actually holding this frame back? Null
306
+ when the frame is held by its category alone — the ordinary 0.5.7 case. */
307
+ function holdingService(src) {
308
+ var ck = api();
309
+ if (!ck || typeof ck._serviceForUrl !== 'function') return null;
310
+ try {
311
+ var svc = ck._serviceForUrl(src);
312
+ if (!svc) return null;
313
+ // Only when the SERVICE is the reason: with the category still denied the
314
+ // category is the honest thing to name, because granting the service
315
+ // alone would not bring the frame back.
316
+ if (typeof ck.allowed === 'function' && !ck.allowed(svc.category)) return null;
317
+ if (typeof ck.allowedService !== 'function') return null;
318
+ return ck.allowedService(svc.id) ? null : svc;
319
+ } catch (e) { return null; }
320
+ }
321
+
322
+ /* Hostname of a blocked frame's real address, for the sentence above. The
323
+ data-src is whatever the page asked for, which may be protocol-relative or
324
+ relative, so it is resolved against the page like the core does it. */
325
+ function hostOf(src) {
326
+ var s = String(src || '');
327
+ try {
328
+ var base = (typeof location !== 'undefined' && location.href) || 'http://localhost/';
329
+ var h = new URL(s, base).hostname || '';
330
+ return h.toLowerCase().replace(/^www\./, '');
331
+ } catch (e) {
332
+ var m = /^(?:[a-z]+:)?\/\/([^/?#]+)/i.exec(s);
333
+ return m ? m[1].toLowerCase().replace(/:\d+$/, '').replace(/^www\./, '') : '';
334
+ }
335
+ }
336
+
164
337
  /* --------------------------------------------------------------- styles */
165
338
 
166
339
  var CSS = [
@@ -285,6 +458,28 @@
285
458
  '.ck-cat__badge{font-size:12px;font-weight:500;color:var(--ck-muted);',
286
459
  'border:1px solid var(--ck-line);border-radius:999px;padding:1px 8px}',
287
460
  '.ck-cat__desc{margin:4px 0 0;font-size:13.5px;color:var(--ck-muted)}',
461
+ /* SPEC V1.12 §3 — «N сервисов · M cookie». --ck-muted, like every other
462
+ secondary label on the card, and it is measured for AA against the card
463
+ background by the same rule the description above answers to. */
464
+ '.ck-cat__count{font-size:12px;font-weight:500;color:var(--ck-muted)}',
465
+
466
+ /* ---- services inside a group (SPEC V1.12 §3) ---- */
467
+ /* Indented and rule-separated so the nesting reads without colour: a
468
+ service belongs to the group above it, and its own cookie table belongs
469
+ to it. The left border is the only decoration; everything else is
470
+ spacing, which survives forced-colours mode intact. */
471
+ '.ck-svcs{margin:12px 0 0;padding-left:12px;border-left:2px solid var(--ck-line)}',
472
+ '.ck-svc{padding:10px 0;border-bottom:1px solid var(--ck-line)}',
473
+ '.ck-svc:first-child{padding-top:2px}',
474
+ '.ck-svc:last-child{border-bottom:0;padding-bottom:2px}',
475
+ '.ck-svc__top{display:flex;gap:12px;align-items:flex-start}',
476
+ '.ck-svc__txt{flex:1 1 auto;min-width:0}',
477
+ '.ck-svc__name{font-size:14px;font-weight:600}',
478
+ '.ck-svc__vendor{margin:2px 0 0;font-size:12.5px;color:var(--ck-muted)}',
479
+ '.ck-svc__desc{margin:4px 0 0;font-size:13px;color:var(--ck-muted)}',
480
+ /* --ck-link, not --ck-accent: accent-coloured TEXT goes through the same
481
+ >= 4.5:1 rule as «Подробнее» — see the note on the `a{}` rule above. */
482
+ '.ck-svc__policy{display:inline-block;margin-top:4px;font-size:12.5px;color:var(--ck-link)}',
288
483
 
289
484
  /* ---- switch ---- */
290
485
  '.ck-switch{flex:none;width:46px;height:27px;padding:0;border-radius:999px;',
@@ -294,6 +489,11 @@
294
489
  '.ck-switch[aria-checked="true"]{background:var(--ck-accent);border-color:var(--ck-accent)}',
295
490
  '.ck-switch[aria-checked="true"]::after{left:auto;right:2px;border-color:transparent}',
296
491
  '.ck-switch[disabled]{cursor:not-allowed;opacity:.55}',
492
+ /* The service switch: the same control, smaller. Still 27px of vertical
493
+ hit area at the row level and a real <button role="switch">, so the
494
+ keyboard and screen-reader behaviour is identical to the group's. */
495
+ '.ck-switch--sm{width:38px;height:22px}',
496
+ '.ck-switch--sm::after{width:16px;height:16px}',
297
497
 
298
498
  /* ---- cookie table ---- */
299
499
  '.ck-det{margin-top:12px}',
@@ -434,6 +634,7 @@
434
634
  var LANG = 'en'; // the code T was built from, reassigned per mount
435
635
  var nodes = {}; // banner/panel/fab refs
436
636
  var switches = {}; // category -> button
637
+ var serviceSwitches = {}; // category -> [button], SPEC V1.12 §3
437
638
  var panelOpen = false;
438
639
  var lastFocus = null;
439
640
 
@@ -463,6 +664,92 @@
463
664
  return out;
464
665
  }
465
666
 
667
+ /* ------------------------------------------------------- services (§3) */
668
+
669
+ /* The services of one category, as the CORE normalised them.
670
+
671
+ Read through ConsentKit._services() rather than off config.services
672
+ directly: the core drops rows with `enabled: false`, a malformed id or an
673
+ unknown category, and the panel must list exactly the set the engine
674
+ blocks by. A config the core has not seen (no init(), or a core too old to
675
+ know about services) yields nothing, and the panel renders as it did in
676
+ 0.5.7 — which is what «старый конфиг рендерится как раньше» asks for. */
677
+ function servicesFor(cat) {
678
+ var ck = api();
679
+ var list = null;
680
+ try {
681
+ if (ck && typeof ck._services === 'function') list = ck._services();
682
+ } catch (e) { list = null; }
683
+ if (!list || !Array.isArray(list)) return [];
684
+ var out = [];
685
+ for (var i = 0; i < list.length; i++) {
686
+ if (list[i] && list[i].category === cat) out.push(list[i]);
687
+ }
688
+ return out;
689
+ }
690
+
691
+ /* The service's one-line purpose, in the banner's language.
692
+
693
+ §3 asks for «одна строка purpose на языке баннера» and §2 ships
694
+ `purpose: { ru?, ro?, en? }`. Falls back to en, then to nothing at all —
695
+ an empty purpose renders no paragraph rather than the string "undefined"
696
+ or a stray language. LANG is the resolved banner code, so a `pt-BR`
697
+ banner asks for `pt` and lands on en, which is the honest answer. */
698
+ function servicePurpose(svc, lang) {
699
+ var p = (svc && svc.purpose) || {};
700
+ var code = String(lang == null ? LANG : lang || 'en').slice(0, 2).toLowerCase();
701
+ var v = p[code] || p.en;
702
+ return (typeof v === 'string' && v.trim()) ? v.trim() : '';
703
+ }
704
+
705
+ /* The cookieTable rows this service claims, by name.
706
+
707
+ §3: «под сервисом его cookie (имя · срок · назначение)» drawn «from
708
+ cookieTable rows whose name is in service.cookies». A name the service
709
+ declares but the table does not describe is NOT invented here: the panel
710
+ shows what the owner wrote down, and the declaration page is where the
711
+ full list lives. */
712
+ function cookieRowsForService(rows, svc) {
713
+ var names = (svc && svc.cookies) || [];
714
+ if (!names.length) return [];
715
+ var out = [];
716
+ for (var i = 0; i < rows.length; i++) {
717
+ var n = rows[i] && rows[i].name;
718
+ if (typeof n === 'string' && names.indexOf(n) > -1) out.push(rows[i]);
719
+ }
720
+ return out;
721
+ }
722
+
723
+ /* SPEC V1.12 §3 — «N сервисов · M cookie» on the group header. Pure, so the
724
+ wording in each of the three languages is testable without a DOM.
725
+
726
+ The cookie half counts the WHOLE group, services and loose rows alike: the
727
+ line answers «сколько cookie в этой группе», which is the question a
728
+ visitor scanning the header is actually asking. */
729
+ function groupCountLabel(serviceCount, cookieCount, strings, lang) {
730
+ return plural(strings.svcCount, serviceCount, lang) + ' · ' +
731
+ plural(strings.ckCount, cookieCount, lang);
732
+ }
733
+
734
+ /* Rows left over once every service has taken its own — «Cookie в этой
735
+ группе (N)» keeps meaning «the rest», exactly as it did before services
736
+ existed. With no services at all this returns the whole list unchanged. */
737
+ function looseCookies(rows, svcs) {
738
+ if (!svcs.length) return rows;
739
+ var claimed = {};
740
+ for (var i = 0; i < svcs.length; i++) {
741
+ var names = svcs[i].cookies || [];
742
+ for (var j = 0; j < names.length; j++) claimed[names[j]] = true;
743
+ }
744
+ var out = [];
745
+ for (var k = 0; k < rows.length; k++) {
746
+ var n = rows[k] && rows[k].name;
747
+ if (typeof n === 'string' && claimed[n]) continue;
748
+ out.push(rows[k]);
749
+ }
750
+ return out;
751
+ }
752
+
466
753
  /* ----------------------------------------------------------------- theme */
467
754
 
468
755
  // Built-in palettes. Dark values are picked for >= 4.5:1 text contrast.
@@ -1195,11 +1482,155 @@
1195
1482
  b.addEventListener('click', function () {
1196
1483
  var on = b.getAttribute('aria-checked') === 'true';
1197
1484
  b.setAttribute('aria-checked', on ? 'false' : 'true');
1485
+ /* SPEC V1.12 §3 — the group switch is the master:
1486
+ off -> every service of the group goes off and is blocked;
1487
+ on -> the services come back, EXCEPT the ones turned off by hand.
1488
+ The hand-set state is kept on the service switch itself (dataset.man)
1489
+ rather than being read back off the group, which is what lets a
1490
+ manual refusal survive the group being toggled off and on again. */
1491
+ syncGroup(cat);
1198
1492
  });
1199
1493
  }
1200
1494
  return b;
1201
1495
  }
1202
1496
 
1497
+ /* SPEC V1.12 §3 — one service, with its own switch, its purpose, its policy
1498
+ link and the cookies it sets. */
1499
+ function makeServiceSwitch(svc, cat) {
1500
+ var b = el('button', 'ck-switch ck-switch--sm');
1501
+ b.type = 'button';
1502
+ b.setAttribute('role', 'switch');
1503
+ b.setAttribute('aria-checked', 'false');
1504
+ b.dataset.svc = svc.id;
1505
+ b.dataset.cat = cat;
1506
+ // '1' once the visitor has switched this service off by hand. Read by
1507
+ // syncGroup() when the group comes back on, and cleared when they switch it
1508
+ // on again — a service the visitor re-enables is no longer «отключён вручную».
1509
+ b.dataset.man = '';
1510
+ /* A service switch is only ever clickable while its group is ON: syncGroup()
1511
+ disables it otherwise, and a disabled <button> fires no click. So this
1512
+ handler always runs with the group on, and there is no "turn the group
1513
+ back on too" case to handle — the visitor reaches a refused group through
1514
+ the group's own switch. */
1515
+ b.addEventListener('click', function () {
1516
+ var on = b.getAttribute('aria-checked') === 'true';
1517
+ b.setAttribute('aria-checked', on ? 'false' : 'true');
1518
+ // '1' = «switched off by hand». Cleared when it is switched back on, so a
1519
+ // re-enabled service is no longer «отключён вручную» and follows its group.
1520
+ b.dataset.man = on ? '1' : '';
1521
+ });
1522
+ return b;
1523
+ }
1524
+
1525
+ /* Pushes the group switch's state down onto its services. Called when the
1526
+ group is clicked and when the panel is synced from the stored state. */
1527
+ function syncGroup(cat) {
1528
+ var on = !!(switches[cat] && switches[cat].getAttribute('aria-checked') === 'true');
1529
+ var list = serviceSwitches[cat] || [];
1530
+ for (var i = 0; i < list.length; i++) {
1531
+ var b = list[i];
1532
+ // Group off: everything off. Group on: everything on except what the
1533
+ // visitor turned off by hand.
1534
+ var want = on && b.dataset.man !== '1';
1535
+ b.setAttribute('aria-checked', want ? 'true' : 'false');
1536
+ // A service cannot be granted while its group is refused, and a switch
1537
+ // that looks operable but changes nothing is worse than a disabled one.
1538
+ b.disabled = !on;
1539
+ if (!on) b.setAttribute('aria-disabled', 'true');
1540
+ else b.removeAttribute('aria-disabled');
1541
+ }
1542
+ }
1543
+
1544
+ function buildService(svc, cat, rows) {
1545
+ var wrap = el('div', 'ck-svc');
1546
+ var top = el('div', 'ck-svc__top');
1547
+ var txt = el('div', 'ck-svc__txt');
1548
+
1549
+ var nameId = 'ck-svc-' + svc.id;
1550
+ var name = el('div', 'ck-svc__name');
1551
+ var nameSpan = el('span', null, svc.name);
1552
+ nameSpan.id = nameId;
1553
+ name.appendChild(nameSpan);
1554
+ txt.appendChild(name);
1555
+
1556
+ if (svc.vendor) txt.appendChild(el('p', 'ck-svc__vendor', svc.vendor));
1557
+
1558
+ var descId = null;
1559
+ var purpose = servicePurpose(svc);
1560
+ if (purpose) {
1561
+ descId = nameId + '-desc';
1562
+ var p = el('p', 'ck-svc__desc', purpose);
1563
+ p.id = descId;
1564
+ txt.appendChild(p);
1565
+ }
1566
+
1567
+ /* «Политика» — target=_blank rel=noopener, per §3. The URL is already
1568
+ http(s)-validated by the core's normalizeService(), which is where a
1569
+ javascript: address is dropped; nothing unvalidated reaches an href. */
1570
+ if (svc.privacyUrl) {
1571
+ var a = el('a', 'ck-svc__policy', T.svcPolicy);
1572
+ a.href = svc.privacyUrl;
1573
+ a.target = '_blank';
1574
+ a.rel = 'noopener noreferrer';
1575
+ // The link text is the same word on every row, so a screen reader needs
1576
+ // the service name to tell them apart.
1577
+ a.setAttribute('aria-label', T.svcPolicy + ' — ' + svc.name);
1578
+ txt.appendChild(a);
1579
+ }
1580
+
1581
+ var sw = makeServiceSwitch(svc, cat);
1582
+ sw.setAttribute('aria-labelledby', nameId);
1583
+ if (descId) sw.setAttribute('aria-describedby', descId);
1584
+ if (!serviceSwitches[cat]) serviceSwitches[cat] = [];
1585
+ serviceSwitches[cat].push(sw);
1586
+
1587
+ top.appendChild(txt);
1588
+ top.appendChild(sw);
1589
+ wrap.appendChild(top);
1590
+
1591
+ var own = cookieRowsForService(rows, svc);
1592
+ if (own.length) wrap.appendChild(cookieTable(own, T.svcCookies + ' (' + own.length + ')'));
1593
+
1594
+ return wrap;
1595
+ }
1596
+
1597
+ /* The <details> block a group and a service both use for their cookies:
1598
+ same columns, same markup, one summary line apart. */
1599
+ function cookieTable(rows, summaryText) {
1600
+ var det = el('details', 'ck-det');
1601
+ var sum = el('summary');
1602
+ sum.appendChild(document.createTextNode(summaryText));
1603
+ det.appendChild(sum);
1604
+
1605
+ var tw = el('div', 'ck-tablewrap');
1606
+ var table = el('table');
1607
+ var thead = el('thead');
1608
+ var htr = el('tr');
1609
+ var heads = [T.colName, T.colVendor, T.colPurpose, T.colExpiry];
1610
+ for (var h = 0; h < heads.length; h++) {
1611
+ var th = el('th', null, heads[h]);
1612
+ th.setAttribute('scope', 'col');
1613
+ htr.appendChild(th);
1614
+ }
1615
+ thead.appendChild(htr);
1616
+ table.appendChild(thead);
1617
+
1618
+ var tbody = el('tbody');
1619
+ for (var r = 0; r < rows.length; r++) {
1620
+ var row = rows[r];
1621
+ var tr = el('tr');
1622
+ tr.appendChild(el('td', 'ck-mono', String(row.name == null ? '—' : row.name)));
1623
+ tr.appendChild(el('td', null, String(row.vendor == null ? '—' : row.vendor)));
1624
+ tr.appendChild(el('td', null, String(row.purpose == null ? '—' : row.purpose)));
1625
+ tr.appendChild(el('td', null, String(row.expiry == null ? '—' : row.expiry)));
1626
+ tbody.appendChild(tr);
1627
+ }
1628
+ table.appendChild(tbody);
1629
+ tw.appendChild(table);
1630
+ det.appendChild(tw);
1631
+ return det;
1632
+ }
1633
+
1203
1634
  function buildCategory(cfg, cat) {
1204
1635
  var meta = T.cat[cat] || { title: cat, desc: '' };
1205
1636
  var locked = cat === 'necessary';
@@ -1221,6 +1652,21 @@
1221
1652
  desc.id = descId;
1222
1653
  txt.appendChild(desc);
1223
1654
 
1655
+ var rows = cookiesFor(cfg, cat);
1656
+ var svcs = servicesFor(cat);
1657
+
1658
+ /* SPEC V1.12 §3 — «N сервисов · M cookie» on the group header.
1659
+
1660
+ Rendered ONLY when the group actually has services. A site whose config
1661
+ predates 0.5.8 has none, and must look exactly as it did in 0.5.7 — not
1662
+ «0 сервисов · 3 cookie». The cookie half counts the whole group, services
1663
+ and loose rows alike: it answers «сколько cookie в этой группе», which is
1664
+ the question the line is there to answer. */
1665
+ if (svcs.length) {
1666
+ name.appendChild(el('span', 'ck-cat__count',
1667
+ groupCountLabel(svcs.length, rows.length, T, LANG)));
1668
+ }
1669
+
1224
1670
  var sw = makeSwitch(cat, locked);
1225
1671
  sw.setAttribute('aria-labelledby', nameId);
1226
1672
  sw.setAttribute('aria-describedby', descId);
@@ -1230,40 +1676,24 @@
1230
1676
  top.appendChild(sw);
1231
1677
  wrap.appendChild(top);
1232
1678
 
1233
- var rows = cookiesFor(cfg, cat);
1234
- if (rows.length) {
1235
- var det = el('details', 'ck-det');
1236
- var sum = el('summary');
1237
- sum.appendChild(document.createTextNode(T.cookiesIn + ' (' + rows.length + ')'));
1238
- det.appendChild(sum);
1239
-
1240
- var tw = el('div', 'ck-tablewrap');
1241
- var table = el('table');
1242
- var thead = el('thead');
1243
- var htr = el('tr');
1244
- var heads = [T.colName, T.colVendor, T.colPurpose, T.colExpiry];
1245
- for (var h = 0; h < heads.length; h++) {
1246
- var th = el('th', null, heads[h]);
1247
- th.setAttribute('scope', 'col');
1248
- htr.appendChild(th);
1679
+ if (svcs.length) {
1680
+ var list = el('div', 'ck-svcs');
1681
+ // A list, so a screen reader announces «3 items» before reading them.
1682
+ list.setAttribute('role', 'list');
1683
+ for (var s = 0; s < svcs.length; s++) {
1684
+ var item = buildService(svcs[s], cat, rows);
1685
+ item.setAttribute('role', 'listitem');
1686
+ list.appendChild(item);
1249
1687
  }
1250
- thead.appendChild(htr);
1251
- table.appendChild(thead);
1252
-
1253
- var tbody = el('tbody');
1254
- for (var r = 0; r < rows.length; r++) {
1255
- var row = rows[r];
1256
- var tr = el('tr');
1257
- tr.appendChild(el('td', 'ck-mono', String(row.name == null ? '—' : row.name)));
1258
- tr.appendChild(el('td', null, String(row.vendor == null ? '—' : row.vendor)));
1259
- tr.appendChild(el('td', null, String(row.purpose == null ? '—' : row.purpose)));
1260
- tr.appendChild(el('td', null, String(row.expiry == null ? '—' : row.expiry)));
1261
- tbody.appendChild(tr);
1262
- }
1263
- table.appendChild(tbody);
1264
- tw.appendChild(table);
1265
- det.appendChild(tw);
1266
- wrap.appendChild(det);
1688
+ wrap.appendChild(list);
1689
+ }
1690
+
1691
+ // «Cookie в этой группе (N)» keeps its old meaning: what is left once each
1692
+ // service has claimed its own. With no services that is the whole table and
1693
+ // the summary line is byte-for-byte what 0.5.7 rendered.
1694
+ var loose = looseCookies(rows, svcs);
1695
+ if (loose.length) {
1696
+ wrap.appendChild(cookieTable(loose, T.cookiesIn + ' (' + loose.length + ')'));
1267
1697
  }
1268
1698
 
1269
1699
  return wrap;
@@ -1288,12 +1718,30 @@
1288
1718
  var url = str(texts.policyUrl);
1289
1719
  if (url && !/^https?:\/\//i.test(url)) url = null;
1290
1720
 
1721
+ /* SPEC V1.10 §1 — the third destination: our own cookie declaration page.
1722
+ `declarationUrl` is server-owned (the SaaS config injects it, like
1723
+ `branding`); the client only reads it and never invents one. It is
1724
+ validated exactly like policyUrl — http(s) only, because a link the
1725
+ visitor is invited to click must not be able to carry javascript: — and
1726
+ it deliberately does NOT influence the default action: the URL-sensitive
1727
+ default above stays policyUrl-driven, so a site that gains a declaration
1728
+ address does not silently lose its «Подробнее» → политика link. */
1729
+ var decl = str(texts.declarationUrl);
1730
+ if (decl && !/^https?:\/\//i.test(decl)) decl = null;
1731
+
1291
1732
  var action = texts.detailsAction;
1292
- if (action !== 'policy' && action !== 'settings' && action !== 'hide') {
1733
+ if (action !== 'policy' && action !== 'settings' && action !== 'hide' && action !== 'declaration') {
1293
1734
  action = url ? 'policy' : 'settings';
1294
1735
  }
1295
1736
  if (action === 'policy' && !url) action = 'settings';
1296
- return { kind: action, href: action === 'policy' ? url : null };
1737
+ // Asked for the declaration with no address to send anyone to: fall back to
1738
+ // opening the settings rather than rendering a dead link.
1739
+ if (action === 'declaration' && !decl) action = 'settings';
1740
+
1741
+ var href = null;
1742
+ if (action === 'policy') href = url;
1743
+ else if (action === 'declaration') href = decl;
1744
+ return { kind: action, href: href };
1297
1745
  }
1298
1746
 
1299
1747
  // Unknown type -> bar/bottom. Known type with an unrecognized position ->
@@ -1335,11 +1783,15 @@
1335
1783
  p.appendChild(document.createTextNode(T.bannerText));
1336
1784
  } else {
1337
1785
  p.appendChild(document.createTextNode(T.bannerText + ' '));
1338
- if (det.kind === 'policy') {
1786
+ // 'policy' and 'declaration' are the same DOM shape — an outbound link in
1787
+ // a new tab — and differ only in where they point (resolveDetails picked
1788
+ // the address). Both must be listed here: a missing branch would silently
1789
+ // render 'declaration' as the settings BUTTON instead of the link.
1790
+ if (det.kind === 'policy' || det.kind === 'declaration') {
1339
1791
  var link = el('a', 'ck-banner__more', T.more);
1340
1792
  link.href = det.href;
1341
1793
  link.target = '_blank';
1342
- link.rel = 'noopener'; // never hand the policy page window.opener
1794
+ link.rel = 'noopener'; // never hand the linked page window.opener
1343
1795
  p.appendChild(link);
1344
1796
  } else {
1345
1797
  // A control that changes what is on screen is a button, not a link:
@@ -1517,6 +1969,32 @@
1517
1969
  var sw = switches[k];
1518
1970
  out[k] = !!(sw && sw.getAttribute('aria-checked') === 'true');
1519
1971
  }
1972
+ /* SPEC V1.12 §3 — «хранение: только отказы».
1973
+
1974
+ Read from `dataset.man`, NOT from `aria-checked`. The two answer different
1975
+ questions and only one of them is the visitor's decision:
1976
+
1977
+ aria-checked — what the switch currently SHOWS. syncGroup() forces every
1978
+ service of a refused group to `false`, so reading this
1979
+ would record «I refused all six of these» the moment the
1980
+ group went off.
1981
+ dataset.man — «the visitor switched this one off by hand». Set on click,
1982
+ restored from the stored record, and deliberately left
1983
+ alone by syncGroup().
1984
+
1985
+ Getting this backwards loses a denial: refuse Hotjar, later switch
1986
+ analytics off and save, and the refusal would be erased — so when
1987
+ analytics came back on, Hotjar would run again without ever having been
1988
+ re-consented to. */
1989
+ var svcs = {};
1990
+ for (var c = 0; c < OPT_IN.length; c++) {
1991
+ var list = serviceSwitches[OPT_IN[c]] || [];
1992
+ for (var j = 0; j < list.length; j++) {
1993
+ var b = list[j];
1994
+ if (b.dataset.man === '1') svcs[b.dataset.svc] = false;
1995
+ }
1996
+ }
1997
+ out.services = svcs;
1520
1998
  return out;
1521
1999
  }
1522
2000
 
@@ -1595,6 +2073,23 @@
1595
2073
  sw.setAttribute('aria-checked', on ? 'true' : 'false');
1596
2074
  }
1597
2075
  if (switches.necessary) switches.necessary.setAttribute('aria-checked', 'true');
2076
+
2077
+ /* SPEC V1.12 §3 — restore the per-service switches from the stored denials.
2078
+
2079
+ `dataset.man` is set from the record FIRST, then syncGroup() derives what
2080
+ each switch shows from it. That ordering is the whole point: a visitor who
2081
+ denied Hotjar and left analytics off must still see Hotjar's own switch
2082
+ off when they turn analytics back on, and the group→service push is what
2083
+ would otherwise light it up again. */
2084
+ var denials = (state && state.services) || {};
2085
+ for (var c = 0; c < OPT_IN.length; c++) {
2086
+ var cat = OPT_IN[c];
2087
+ var list = serviceSwitches[cat] || [];
2088
+ for (var j = 0; j < list.length; j++) {
2089
+ list[j].dataset.man = denials[list[j].dataset.svc] === false ? '1' : '';
2090
+ }
2091
+ syncGroup(cat);
2092
+ }
1598
2093
  }
1599
2094
 
1600
2095
  // Idempotent: safe to call from ck:init, ck:change and right after our own API calls.
@@ -1607,6 +2102,333 @@
1607
2102
  if (nodes.scrim) nodes.scrim.classList.toggle('ck-hidden', decided || !nodes.bannerModal);
1608
2103
  if (nodes.fab) nodes.fab.classList.toggle('ck-hidden', !decided);
1609
2104
  if (!panelOpen) syncSwitches(s);
2105
+
2106
+ /* §2 — a consent change is exactly when a card must go: the core's
2107
+ applyConsentToDom() has already put the frame's src back by the time
2108
+ ck:change reaches us (commit() revives before it dispatches), so the
2109
+ sweep sees a frame with a src and retires its placeholder. It runs on
2110
+ every sync, which also catches a category the visitor turned back OFF —
2111
+ though a frame that already loaded cannot be un-loaded, so that direction
2112
+ only matters for frames still held back. */
2113
+ try { sweepPlaceholders(); } catch (e) { /* noop */ }
2114
+ }
2115
+
2116
+ /* ------------------------------------------- blocked-embed placeholders (§2) */
2117
+
2118
+ /* SPEC V1.10 §2. When the core holds an <iframe> back — a known tracker, or
2119
+ any third-party frame in strict mode — it leaves it in one shape:
2120
+ `data-ck` + `data-src` and NO src (markBlockedIframe in ck-core.js). This
2121
+ module draws a block of the same size in its place, offering the visitor
2122
+ the one decision that would make the embed appear.
2123
+
2124
+ WHY A SIBLING, NOT A WRAPPER: sites style embeds through the parent
2125
+ (`.video-wrap > iframe`, grid children, aspect-ratio boxes). Wrapping the
2126
+ frame inserts a node into that relationship and breaks the layout it was
2127
+ meant to preserve. A sibling inserted before the frame inherits the same
2128
+ parent context, and removing it later restores the DOM exactly.
2129
+
2130
+ WHO RESTORES THE FRAME: not this file. `Element.prototype.setAttribute` is
2131
+ patched by the core, and only applyConsentToDom() may write a blocked
2132
+ frame's src — under `bypass`, through the native setter it captured before
2133
+ patching. So «Разрешить и показать» grants the category through the normal
2134
+ consent path and the CORE brings the frame back; the next sweep sees a
2135
+ frame that has a src again and drops the placeholder. One code path for
2136
+ the button, the panel's switches and «Принять всё» alike. */
2137
+
2138
+ var PH_ATTR = 'data-ck-ph'; // marks a frame this file hid
2139
+ var PH_DISPLAY = 'data-ck-ph-display'; // its previous inline display value
2140
+ var placeholders = []; // [{ frame, node }]
2141
+
2142
+ function placeholdersEnabled(cfg) {
2143
+ // No default in the core's DEFAULT_CONFIG, exactly like detailsAction:
2144
+ // absent means on, and only an explicit false opts out.
2145
+ try {
2146
+ var b = cfg && cfg.blocking;
2147
+ return !(b && b.placeholders === false);
2148
+ } catch (e) { return true; }
2149
+ }
2150
+
2151
+ /* §2: «Не трогать фреймы display:none, 1×1, и те, что не в <body>.»
2152
+ Order matters — a frame this file has already hidden reads as display:none,
2153
+ which would make it skip its own placeholder and leave it stranded forever.
2154
+ Frames we marked are therefore exempted before the display test runs. */
2155
+ function frameEligible(frame) {
2156
+ try {
2157
+ if (!frame || !document.body) return false;
2158
+ if (!document.body.contains(frame)) return false;
2159
+ if (frame.getAttribute(PH_ATTR)) return true; // ours: already measured
2160
+ var cs = null;
2161
+ try { cs = window.getComputedStyle(frame); } catch (e) { cs = null; }
2162
+ if (cs && (cs.display === 'none' || cs.visibility === 'hidden')) return false;
2163
+ var r = null;
2164
+ try { r = frame.getBoundingClientRect(); } catch (e) { r = null; }
2165
+ // A 1×1 (or 0×0) frame is a tracking pixel dressed as an embed: there is
2166
+ // nothing for a visitor to watch and a card in its place would be noise.
2167
+ if (r && r.width <= 1 && r.height <= 1) {
2168
+ // Zero-sized because it is not laid out YET (a lazy tab, a collapsed
2169
+ // section) is indistinguishable here from a real pixel except via the
2170
+ // attributes, which a pixel sets to 1 and a video does not.
2171
+ var aw = parseInt(frame.getAttribute('width') || '0', 10);
2172
+ var ah = parseInt(frame.getAttribute('height') || '0', 10);
2173
+ if (!(aw > 1 || ah > 1)) return false;
2174
+ }
2175
+ return true;
2176
+ } catch (e) { return false; }
2177
+ }
2178
+
2179
+ /* Size the card to the hole the frame left. Attributes first (an embed is
2180
+ nearly always `width="560" height="315"`), computed size second. Read
2181
+ BEFORE the frame is hidden — getComputedStyle on a display:none element
2182
+ reports nothing worth copying. */
2183
+ function frameSize(frame) {
2184
+ var w = '', h = '';
2185
+ try {
2186
+ var aw = frame.getAttribute('width');
2187
+ var ah = frame.getAttribute('height');
2188
+ if (aw && /^\d+$/.test(String(aw).trim())) w = String(aw).trim() + 'px';
2189
+ if (ah && /^\d+$/.test(String(ah).trim())) h = String(ah).trim() + 'px';
2190
+ if (!w || !h) {
2191
+ var cs = window.getComputedStyle(frame);
2192
+ if (!w && cs && cs.width && cs.width !== 'auto' && cs.width !== '0px') w = cs.width;
2193
+ if (!h && cs && cs.height && cs.height !== 'auto' && cs.height !== '0px') h = cs.height;
2194
+ }
2195
+ } catch (e) { /* fall through to the defaults in PH_CSS */ }
2196
+ return { width: w, height: h };
2197
+ }
2198
+
2199
+ var PLAY_ICON =
2200
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" ' +
2201
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' +
2202
+ '<rect x="2.5" y="4.5" width="19" height="15" rx="2.5"/>' +
2203
+ '<path d="M10.5 9.2v5.6l4.6-2.8-4.6-2.8Z"/></svg>';
2204
+
2205
+ // Own sheet: a placeholder is its own shadow root, so it cannot borrow the
2206
+ // banner's. Tokens come from buildThemeCss() (theme.accent, radius, font),
2207
+ // which is why a placeholder repaints with the banner.
2208
+ var PH_CSS = [
2209
+ ':host{all:initial;display:block;max-width:100%}',
2210
+ '*,*::before,*::after{box-sizing:border-box}',
2211
+ '.ck-ph{width:100%;height:100%;min-height:120px;max-width:100%;',
2212
+ 'display:flex;flex-direction:column;align-items:center;justify-content:center;',
2213
+ 'gap:10px;padding:20px;text-align:center;',
2214
+ 'font-family:var(--ck-font);font-size:14px;line-height:1.5;color:var(--ck-ink);',
2215
+ 'background:var(--ck-soft);border:1px solid var(--ck-line);',
2216
+ 'border-radius:var(--ck-radius-card)}',
2217
+ '.ck-ph svg{width:32px;height:32px;display:block;color:var(--ck-muted);flex:none}',
2218
+ '.ck-ph p{margin:0;color:var(--ck-muted);max-width:44ch}',
2219
+ '.ck-ph__row{display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:center}',
2220
+ '.ck-ph__btn{font:inherit;font-weight:600;cursor:pointer;padding:9px 16px;',
2221
+ 'border-radius:var(--ck-radius-btn);border:1px solid var(--ck-accent);',
2222
+ 'background:var(--ck-accent);color:var(--ck-on-accent)}',
2223
+ '.ck-ph__link{font:inherit;background:none;border:0;padding:4px;cursor:pointer;',
2224
+ 'color:var(--ck-link);text-decoration:underline}',
2225
+ ':focus-visible{outline:2px solid var(--ck-accent);outline-offset:2px;border-radius:4px}'
2226
+ ].join('\n');
2227
+
2228
+ function categoryEnabled(cfg, cat) {
2229
+ try {
2230
+ if (cat === 'necessary') return false; // nothing to grant
2231
+ var c = cfg && cfg.categories && cfg.categories[cat];
2232
+ return !c || c.enabled !== false;
2233
+ } catch (e) { return true; }
2234
+ }
2235
+
2236
+ function buildPlaceholder(frame, cat, cfg) {
2237
+ var mountEl = document.createElement('div');
2238
+ mountEl.setAttribute('data-ck-placeholder', '1');
2239
+ var size = frameSize(frame);
2240
+ var st = mountEl.style;
2241
+ st.setProperty('max-width', '100%');
2242
+ if (size.width) st.setProperty('width', size.width);
2243
+ if (size.height) st.setProperty('min-height', size.height);
2244
+
2245
+ var sr = mountEl.attachShadow({ mode: 'open' });
2246
+ var sheet = document.createElement('style');
2247
+ /* buildThemeCss writes :host tokens — the same tokens, in this root, which
2248
+ is what makes a placeholder repaint with the banner.
2249
+
2250
+ The font gets applyTheme()'s treatment for the same reason it needs it
2251
+ there: `:host{all:initial}` resets the family, so a bare `inherit` in
2252
+ this root resolves to the UA default (Times), not to the page's type.
2253
+ The probed page font when it can be read, the system stack when it
2254
+ cannot — never a bare `inherit`. */
2255
+ var built = buildThemeCss(cfg);
2256
+ var phFont = '';
2257
+ if (built.font === 'inherit') {
2258
+ phFont = '\n:host{--ck-font:' + (resolvePageFont() || SYSTEM_FONT) + '}';
2259
+ }
2260
+ sheet.textContent = PH_CSS + '\n' + built.css + phFont;
2261
+ sr.appendChild(sheet);
2262
+
2263
+ var card = el('div', 'ck-ph');
2264
+ card.setAttribute('role', 'group');
2265
+ card.setAttribute('aria-label', T.phLabel);
2266
+
2267
+ var icon = document.createElement('div');
2268
+ icon.innerHTML = PLAY_ICON;
2269
+ card.appendChild(icon);
2270
+
2271
+ var frameSrcAttr = frame.getAttribute('data-src') || '';
2272
+ var label = hostOf(frameSrcAttr);
2273
+ // §3: when a refused SERVICE is what is holding this frame, the sentence
2274
+ // must name that service — its category is already granted.
2275
+ var held = holdingService(frameSrcAttr);
2276
+ card.appendChild(el('p', null,
2277
+ placeholderText(label, cat, LANG, held ? held.name : null)));
2278
+
2279
+ var row = el('div', 'ck-ph__row');
2280
+ /* Only offer the grant when it can actually take effect: the core's
2281
+ filterByConfig() drops a category the site disabled in config, so the
2282
+ button would consume the click and change nothing. The panel link stays
2283
+ either way — it is always an honest answer. */
2284
+ if (categoryEnabled(cfg, cat)) {
2285
+ var allow = el('button', 'ck-ph__btn', T.phAllow);
2286
+ allow.type = 'button';
2287
+ // SPEC V1.12 §3: «кнопка "Разрешить и показать" включает категорию и
2288
+ // снимает отказ по этому сервису». The frame's own address decides which
2289
+ // service that is — the visitor pressed the button on THIS embed.
2290
+ allow.addEventListener('click', function () { grantCategory(cat, frameSrcAttr); });
2291
+ row.appendChild(allow);
2292
+ }
2293
+ var settings = el('button', 'ck-ph__link', T.phSettings);
2294
+ settings.type = 'button';
2295
+ settings.addEventListener('click', function () { openPanel(settings); });
2296
+ row.appendChild(settings);
2297
+ card.appendChild(row);
2298
+
2299
+ sr.appendChild(card);
2300
+ return mountEl;
2301
+ }
2302
+
2303
+ /* «Разрешить и показать» — one category, through the normal consent path.
2304
+
2305
+ MERGE, DO NOT REPLACE: ConsentKit.accept({...}) SETS the whole opt-in set
2306
+ from the object it is given, so passing `{marketing:true}` alone would
2307
+ silently switch OFF an analytics consent the visitor had already given.
2308
+ The current state is read first and only the one category is flipped.
2309
+
2310
+ accept(object) files the decision as method 'custom', which is what §2 asks
2311
+ the journal to record, and ck-saas.js logs it off the ck:consent/ck:change
2312
+ the core dispatches — so there is no logging code here. The core's
2313
+ applyConsentToDom() restores the frame; sweepPlaceholders() then removes
2314
+ this card, driven by the ck:change that same commit dispatches. */
2315
+ function grantCategory(cat, src) {
2316
+ var ck = api();
2317
+ if (!ck || typeof ck.accept !== 'function') return;
2318
+ var st = safeState();
2319
+ var cur = st.categories || {};
2320
+ var next = {
2321
+ functional: cur.functional === true,
2322
+ analytics: cur.analytics === true,
2323
+ marketing: cur.marketing === true
2324
+ };
2325
+ if (cat === 'functional' || cat === 'analytics' || cat === 'marketing') next[cat] = true;
2326
+
2327
+ /* SPEC V1.12 §3 — clear the refusal on THIS frame's service, in the SAME
2328
+ accept() call as the category grant.
2329
+
2330
+ One call, not two: the core revives blocked frames inside commit(), so a
2331
+ denial still standing at that moment leaves this frame dead until some
2332
+ later consent change happens to run applyConsentToDom() again. The whole
2333
+ map is passed because accept() replaces it wholesale — every OTHER
2334
+ refusal the visitor made is copied across untouched. */
2335
+ var denials = denialsWithout(st.services, src);
2336
+ if (denials) next.services = denials;
2337
+
2338
+ try { ck.accept(next); } catch (e) { /* noop */ }
2339
+ syncFromState();
2340
+ }
2341
+
2342
+ /* The stored denial map minus the service that owns `src`, or null when
2343
+ nothing would change (no denials, no service for that URL, or that service
2344
+ was not refused in the first place). */
2345
+ function denialsWithout(stored, src) {
2346
+ var ck = api();
2347
+ if (!ck || typeof ck._serviceForUrl !== 'function') return null;
2348
+ var map = {};
2349
+ var any = false;
2350
+ try {
2351
+ Object.keys(stored || {}).forEach(function (k) {
2352
+ if (stored[k] === false) { map[k] = false; any = true; }
2353
+ });
2354
+ } catch (e) { return null; }
2355
+ if (!any) return null;
2356
+
2357
+ var svc = null;
2358
+ try { svc = src ? ck._serviceForUrl(src) : null; } catch (e2) { svc = null; }
2359
+ if (!svc || map[svc.id] !== false) return null;
2360
+ delete map[svc.id];
2361
+ return map;
2362
+ }
2363
+
2364
+ function hideFrame(frame) {
2365
+ try {
2366
+ frame.setAttribute(PH_DISPLAY, frame.style.display || '');
2367
+ frame.setAttribute(PH_ATTR, '1');
2368
+ frame.style.display = 'none';
2369
+ } catch (e) { /* noop */ }
2370
+ }
2371
+
2372
+ function restoreFrame(frame) {
2373
+ try {
2374
+ var prev = frame.getAttribute(PH_DISPLAY);
2375
+ frame.style.display = prev || '';
2376
+ frame.removeAttribute(PH_DISPLAY);
2377
+ frame.removeAttribute(PH_ATTR);
2378
+ } catch (e) { /* noop */ }
2379
+ }
2380
+
2381
+ function dropPlaceholder(entry) {
2382
+ try {
2383
+ if (entry.node && entry.node.parentNode) entry.node.parentNode.removeChild(entry.node);
2384
+ } catch (e) { /* noop */ }
2385
+ restoreFrame(entry.frame);
2386
+ }
2387
+
2388
+ function clearPlaceholders() {
2389
+ for (var i = 0; i < placeholders.length; i++) dropPlaceholder(placeholders[i]);
2390
+ placeholders = [];
2391
+ }
2392
+
2393
+ /* Idempotent: called from mount() and from every consent change. Adds cards
2394
+ for frames still held back, removes them from frames the core revived. */
2395
+ function sweepPlaceholders(cfg) {
2396
+ if (typeof document === 'undefined' || !document.body) return;
2397
+ var c = cfg || safeConfig();
2398
+
2399
+ // Placeholders turned off after some were drawn: take them all down.
2400
+ if (!placeholdersEnabled(c)) { clearPlaceholders(); return; }
2401
+
2402
+ // 1. Retire cards whose frame came back (or left the document).
2403
+ var kept = [];
2404
+ for (var i = 0; i < placeholders.length; i++) {
2405
+ var e = placeholders[i];
2406
+ var revived = false;
2407
+ try {
2408
+ revived = !document.body.contains(e.frame) || !!e.frame.getAttribute('src');
2409
+ } catch (e2) { revived = true; }
2410
+ if (revived) dropPlaceholder(e); else kept.push(e);
2411
+ }
2412
+ placeholders = kept;
2413
+
2414
+ // 2. Draw cards for frames the core is holding back. The selector is the
2415
+ // core's own revival selector, which is why a frame the SITE allowed can
2416
+ // never match: an allowed frame keeps its src and is never marked.
2417
+ var list;
2418
+ try { list = document.querySelectorAll('iframe[data-ck][data-src]'); } catch (e3) { return; }
2419
+ for (var j = 0; j < list.length; j++) {
2420
+ var frame = list[j];
2421
+ try {
2422
+ if (frame.getAttribute('src')) continue; // already revived
2423
+ if (frame.getAttribute(PH_ATTR)) continue; // already carded
2424
+ if (!frameEligible(frame)) continue;
2425
+ var cat = frame.getAttribute('data-ck') || 'marketing';
2426
+ var node = buildPlaceholder(frame, cat, c);
2427
+ if (frame.parentNode) frame.parentNode.insertBefore(node, frame);
2428
+ hideFrame(frame);
2429
+ placeholders.push({ frame: frame, node: node });
2430
+ } catch (e4) { /* one bad frame must not stop the sweep */ }
2431
+ }
1610
2432
  }
1611
2433
 
1612
2434
  /* ----------------------------------------------------------------- mount */
@@ -1629,14 +2451,46 @@
1629
2451
  // and mount() is one-shot. Buttons and colours are deliberately NOT
1630
2452
  // here — they are token values and restyle in place.
1631
2453
  resolveDetails(c).kind,
2454
+ /* SPEC V1.12 §3 — services are STRUCTURAL: each one adds a row with its
2455
+ own switch to the panel, and mount() is one-shot. Without this a SaaS
2456
+ config that arrives after the first mount (the second, idempotent
2457
+ init()) would re-run buildServices() in the core — so the engine would
2458
+ block per service — while the panel kept showing the service-less
2459
+ render, and the visitor would have no way to see or change any of it.
2460
+
2461
+ The COUNT and the ids, not the whole rows: what needs a rebuild is a
2462
+ service appearing, disappearing or changing identity. A reworded
2463
+ `purpose` is text inside an existing row and does not justify tearing
2464
+ the panel down. */
2465
+ serviceSignature(c),
1632
2466
  brandSignature(c)
1633
2467
  ].join('|');
1634
2468
  }
1635
2469
 
2470
+ function serviceSignature(cfg) {
2471
+ var list = cfg && cfg.services;
2472
+ if (!list || !Array.isArray(list) || !list.length) return '0';
2473
+ var ids = [];
2474
+ for (var i = 0; i < list.length; i++) {
2475
+ var s = list[i];
2476
+ if (!s || typeof s !== 'object' || s.enabled === false) continue;
2477
+ ids.push(String(s.id || '') + ':' + String(s.category || ''));
2478
+ }
2479
+ // '0' for «no services», however that came about: an absent key, an empty
2480
+ // array, or a list every row of which the core would drop. All three render
2481
+ // the same panel, so none of them may differ in the signature.
2482
+ if (!ids.length) return '0';
2483
+ return ids.length + ',' + ids.join(',');
2484
+ }
2485
+
1636
2486
  function remount(cfg) {
1637
2487
  mounted = false;
1638
2488
  panelOpen = false;
1639
2489
  lastFocus = null;
2490
+ // The cards belong to the render about to be replaced: their listeners close
2491
+ // over the old T and the old shadow root. mount() sweeps again and draws
2492
+ // fresh ones in the new language and theme.
2493
+ clearPlaceholders();
1640
2494
  // The pending schedule belongs to the shadow root about to be rebuilt; the
1641
2495
  // fresh mount() starts its own ladder from a clean count.
1642
2496
  clearFontTimers();
@@ -1674,6 +2528,7 @@
1674
2528
  root.appendChild(style);
1675
2529
 
1676
2530
  switches = {};
2531
+ serviceSwitches = {};
1677
2532
  nodes = {};
1678
2533
 
1679
2534
  // Palette sheet comes after the base sheet so its :host tokens win.
@@ -1692,6 +2547,55 @@
1692
2547
  // Only now: scheduleFontProbes() re-applies the theme from a timer, and
1693
2548
  // applyTheme() is a no-op until there is a host and a themeStyle to write.
1694
2549
  scheduleFontProbes(cfg);
2550
+
2551
+ // SPEC V1.10 §2 — заглушки вместо задержанных встраиваний. After the shadow
2552
+ // root exists, because a placeholder's «Настроить cookie» calls openPanel().
2553
+ sweepPlaceholders(cfg);
2554
+
2555
+ /* SPEC V1.10 §1 — honour a settings request that arrived before there was
2556
+ anything to open: either ConsentKit.openSettings() called while this file
2557
+ was still loading (the core latches it), or a page opened directly on
2558
+ `#ck-settings`. Both end in the same panel. */
2559
+ consumePendingOpen();
2560
+ openFromHash();
2561
+ }
2562
+
2563
+ /* ---------------------------------------------------- settings deep link */
2564
+
2565
+ var SETTINGS_HASH = '#ck-settings';
2566
+
2567
+ // The core latches openSettings() calls made before ck-ui.js was parsed, so a
2568
+ // footer link clicked during a slow load still opens the panel once we exist.
2569
+ function consumePendingOpen() {
2570
+ var ck = api();
2571
+ if (!ck || !ck._pendingOpen) return;
2572
+ try { ck._pendingOpen = false; } catch (e) { /* noop */ }
2573
+ openPanel(null);
2574
+ }
2575
+
2576
+ /* `https://site/#ck-settings` — the address the cookie declaration page links
2577
+ «Изменить выбор cookie» to. The hash is removed again via replaceState so a
2578
+ reload, a shared link or a back-navigation does not re-open the panel, and
2579
+ so the address bar does not keep a control fragment in it.
2580
+
2581
+ replaceState is fed pathname+search rather than '' — an empty URL argument
2582
+ is a no-op in some engines, which would leave the hash in place and re-open
2583
+ the panel on the next hashchange. Everything is guarded: a sandboxed iframe
2584
+ throws on replaceState, and a panel that opened is worth more than a tidy
2585
+ address bar. */
2586
+ function openFromHash() {
2587
+ try {
2588
+ if (typeof location === 'undefined' || location.hash !== SETTINGS_HASH) return;
2589
+ } catch (e) { return; }
2590
+ clearSettingsHash();
2591
+ openPanel(null);
2592
+ }
2593
+
2594
+ function clearSettingsHash() {
2595
+ try {
2596
+ if (typeof history === 'undefined' || typeof history.replaceState !== 'function') return;
2597
+ history.replaceState(null, '', location.pathname + location.search);
2598
+ } catch (e) { /* noop */ }
1695
2599
  }
1696
2600
 
1697
2601
  /* ---------------------------------------------------------------- events */
@@ -1722,7 +2626,26 @@
1722
2626
  nextProbeDelay: nextProbeDelay,
1723
2627
  shouldReprobe: shouldReprobe,
1724
2628
  resolveDetails: resolveDetails,
1725
- buildThemeCss: buildThemeCss
2629
+ buildThemeCss: buildThemeCss,
2630
+ // SPEC V1.10 §2: which sentence a blocked embed shows. Pure, so the
2631
+ // wording is testable (and quotable by the cabinet) without a DOM.
2632
+ placeholderText: placeholderText,
2633
+ placeholdersEnabled: placeholdersEnabled,
2634
+ /* SPEC V1.12 §3 — the pure halves of the services panel, so the wording
2635
+ and the arithmetic are testable (and quotable by the cabinet) without a
2636
+ DOM: which plural form a count takes in each language, how a group
2637
+ header reads, and which cookieTable rows sit under which service. */
2638
+ plural: plural,
2639
+ pluralIndex: pluralIndex,
2640
+ buildStrings: buildStrings,
2641
+ localeTable: localeTable,
2642
+ resolveLang: resolveLang,
2643
+ cookieRowsForService: cookieRowsForService,
2644
+ looseCookies: looseCookies,
2645
+ servicePurpose: servicePurpose,
2646
+ groupCountLabel: groupCountLabel,
2647
+ serviceSignature: serviceSignature,
2648
+ signature: signature
1726
2649
  };
1727
2650
  // The page-font probe reads the DOM, so it is not part of the pure block —
1728
2651
  // but the debug panel must be able to quote the family the banner painted
@@ -1768,9 +2691,29 @@
1768
2691
 
1769
2692
  document.addEventListener('ck:ui:open-preferences', function () {
1770
2693
  if (!mounted) mount(safeConfig());
2694
+ // mount() consumes the core's latch itself; clear it here too so a call
2695
+ // made while we were already mounted cannot leave a stale flag behind for
2696
+ // a later remount to act on.
2697
+ var ck = api();
2698
+ if (ck && ck._pendingOpen) { try { ck._pendingOpen = false; } catch (e) { /* noop */ } }
1771
2699
  openPanel(null);
1772
2700
  });
1773
2701
 
2702
+ /* SPEC V1.10 §1 — navigating to `#ck-settings` on a page that is already
2703
+ loaded (a footer link, or the declaration page opened in the same tab).
2704
+ mount() covers the other half: a page ENTERED on that hash.
2705
+
2706
+ Feature-checked rather than assumed: the SSR guard above only proves there
2707
+ is a `document`, and the branding suite evaluates this file against a stub
2708
+ window that has none of the event plumbing. A missing hashchange costs the
2709
+ deep link, not the banner. */
2710
+ if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
2711
+ window.addEventListener('hashchange', function () {
2712
+ if (!mounted) mount(safeConfig());
2713
+ openFromHash();
2714
+ });
2715
+ }
2716
+
1774
2717
  document.addEventListener('ck:ui:close', function () {
1775
2718
  closePanel();
1776
2719
  });