@ecomconsult/consentkit 0.5.7 → 0.5.9

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,18 @@
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',
35
47
  // SPEC V1.10 §2 — the blocked-embed placeholder. {host} is the vendor
36
48
  // label when the database knows one and the bare host otherwise; {cat} is
37
49
  // the localized category title, taken from cat.<name>.title below, so the
@@ -80,6 +92,11 @@
80
92
  colExpiry: 'Срок',
81
93
  floating: 'Настройки cookie',
82
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 ставит',
83
100
  phText: 'Здесь содержимое от {host}. Оно загрузится после согласия на «{cat}».',
84
101
  phAllow: 'Разрешить и показать',
85
102
  phSettings: 'Настроить cookie',
@@ -119,9 +136,18 @@
119
136
  // SPEC V1.10 §2. Present in ck-locales.js for ro only; every other external
120
137
  // locale falls back to DICT.en through buildStrings(), which is what §2
121
138
  // asks for («остальные языки — en»).
122
- 'phText', 'phAllow', 'phSettings', 'phLabel'
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'
123
146
  ];
124
147
 
148
+ // Plural-form keys, filled separately from STR_KEYS (see above).
149
+ var PLURAL_KEYS = ['svcCount', 'ckCount'];
150
+
125
151
  // builtin(en,ru) <- window.__ckLocales, read at render time so the locales
126
152
  // file may load in any order relative to this one.
127
153
  function localeTable() {
@@ -153,6 +179,60 @@
153
179
  return 'en';
154
180
  }
155
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
+
156
236
  // Deep two-level fill from en: a partial locale must never yield undefined,
157
237
  // which would render the literal string "undefined".
158
238
  function buildStrings(lang, table) {
@@ -164,6 +244,15 @@
164
244
  var k = STR_KEYS[i];
165
245
  out[k] = (typeof src[k] === 'string' && src[k]) ? src[k] : base[k];
166
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
+
167
256
  out.cat = {};
168
257
  var sc = (src.cat && typeof src.cat === 'object') ? src.cat : {};
169
258
  for (i = 0; i < ALL_CATS.length; i++) {
@@ -193,14 +282,41 @@
193
282
  uses, so «Маркетинг» in the placeholder is «Маркетинг» on the switch. An
194
283
  unknown language falls back to en, an unknown category to marketing — the
195
284
  category a strict-mode interception is filed under. */
196
- function placeholderText(host, category, lang) {
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) {
197
294
  var table = localeTable();
198
295
  var T2 = buildStrings(resolveLang(lang, table), table);
199
296
  var cat = (category && T2.cat[category]) ? category : 'marketing';
200
297
  var name = String(host || '').trim();
298
+ var label = (typeof subject === 'string' && subject.trim())
299
+ ? subject.trim() : T2.cat[cat].title;
201
300
  return T2.phText
202
301
  .replace('{host}', name || T2.phLabel)
203
- .replace('{cat}', T2.cat[cat].title);
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; }
204
320
  }
205
321
 
206
322
  /* Hostname of a blocked frame's real address, for the sentence above. The
@@ -324,9 +440,13 @@
324
440
  'border:1px solid var(--ck-line);border-radius:var(--ck-radius-card);overflow:hidden}',
325
441
  '.ck-panel__head{display:flex;align-items:flex-start;gap:16px;padding:22px 24px 14px;',
326
442
  'border-bottom:1px solid var(--ck-line)}',
443
+ // The text block takes the width and the close button sits at the padding
444
+ // edge, flush with the switches below it: without flex:1 the button
445
+ // followed the text's own wrapped width and floated short of the edge.
446
+ '.ck-panel__head>div{flex:1 1 auto;min-width:0}',
327
447
  '.ck-panel__head h2{margin:0 0 4px;font-size:18px;font-weight:600;letter-spacing:-.01em}',
328
448
  '.ck-panel__head p{margin:0;font-size:14px;color:var(--ck-muted)}',
329
- '.ck-x{flex:none;width:36px;height:36px;border-radius:var(--ck-radius-btn);border:1px solid var(--ck-line);',
449
+ '.ck-x{flex:none;margin-left:auto;width:36px;height:36px;border-radius:var(--ck-radius-btn);border:1px solid var(--ck-line);',
330
450
  'background:transparent;display:inline-flex;align-items:center;justify-content:center;color:var(--ck-muted)}',
331
451
  '.ck-panel__body{overflow:auto;padding:6px 24px 10px;-webkit-overflow-scrolling:touch}',
332
452
  '.ck-panel__foot{display:flex;gap:10px;flex-wrap:wrap;padding:16px 24px;',
@@ -342,6 +462,28 @@
342
462
  '.ck-cat__badge{font-size:12px;font-weight:500;color:var(--ck-muted);',
343
463
  'border:1px solid var(--ck-line);border-radius:999px;padding:1px 8px}',
344
464
  '.ck-cat__desc{margin:4px 0 0;font-size:13.5px;color:var(--ck-muted)}',
465
+ /* SPEC V1.12 §3 — «N сервисов · M cookie». --ck-muted, like every other
466
+ secondary label on the card, and it is measured for AA against the card
467
+ background by the same rule the description above answers to. */
468
+ '.ck-cat__count{font-size:12px;font-weight:500;color:var(--ck-muted)}',
469
+
470
+ /* ---- services inside a group (SPEC V1.12 §3) ---- */
471
+ /* Indented and rule-separated so the nesting reads without colour: a
472
+ service belongs to the group above it, and its own cookie table belongs
473
+ to it. The left border is the only decoration; everything else is
474
+ spacing, which survives forced-colours mode intact. */
475
+ '.ck-svcs{margin:12px 0 0;padding-left:12px;border-left:2px solid var(--ck-line)}',
476
+ '.ck-svc{padding:10px 0;border-bottom:1px solid var(--ck-line)}',
477
+ '.ck-svc:first-child{padding-top:2px}',
478
+ '.ck-svc:last-child{border-bottom:0;padding-bottom:2px}',
479
+ '.ck-svc__top{display:flex;gap:12px;align-items:flex-start}',
480
+ '.ck-svc__txt{flex:1 1 auto;min-width:0}',
481
+ '.ck-svc__name{font-size:14px;font-weight:600}',
482
+ '.ck-svc__vendor{margin:2px 0 0;font-size:12.5px;color:var(--ck-muted)}',
483
+ '.ck-svc__desc{margin:4px 0 0;font-size:13px;color:var(--ck-muted)}',
484
+ /* --ck-link, not --ck-accent: accent-coloured TEXT goes through the same
485
+ >= 4.5:1 rule as «Подробнее» — see the note on the `a{}` rule above. */
486
+ '.ck-svc__policy{display:inline-block;margin-top:4px;font-size:12.5px;color:var(--ck-link)}',
345
487
 
346
488
  /* ---- switch ---- */
347
489
  '.ck-switch{flex:none;width:46px;height:27px;padding:0;border-radius:999px;',
@@ -351,6 +493,11 @@
351
493
  '.ck-switch[aria-checked="true"]{background:var(--ck-accent);border-color:var(--ck-accent)}',
352
494
  '.ck-switch[aria-checked="true"]::after{left:auto;right:2px;border-color:transparent}',
353
495
  '.ck-switch[disabled]{cursor:not-allowed;opacity:.55}',
496
+ /* The service switch: the same control, smaller. Still 27px of vertical
497
+ hit area at the row level and a real <button role="switch">, so the
498
+ keyboard and screen-reader behaviour is identical to the group's. */
499
+ '.ck-switch--sm{width:38px;height:22px}',
500
+ '.ck-switch--sm::after{width:16px;height:16px}',
354
501
 
355
502
  /* ---- cookie table ---- */
356
503
  '.ck-det{margin-top:12px}',
@@ -491,6 +638,7 @@
491
638
  var LANG = 'en'; // the code T was built from, reassigned per mount
492
639
  var nodes = {}; // banner/panel/fab refs
493
640
  var switches = {}; // category -> button
641
+ var serviceSwitches = {}; // category -> [button], SPEC V1.12 §3
494
642
  var panelOpen = false;
495
643
  var lastFocus = null;
496
644
 
@@ -520,6 +668,92 @@
520
668
  return out;
521
669
  }
522
670
 
671
+ /* ------------------------------------------------------- services (§3) */
672
+
673
+ /* The services of one category, as the CORE normalised them.
674
+
675
+ Read through ConsentKit._services() rather than off config.services
676
+ directly: the core drops rows with `enabled: false`, a malformed id or an
677
+ unknown category, and the panel must list exactly the set the engine
678
+ blocks by. A config the core has not seen (no init(), or a core too old to
679
+ know about services) yields nothing, and the panel renders as it did in
680
+ 0.5.7 — which is what «старый конфиг рендерится как раньше» asks for. */
681
+ function servicesFor(cat) {
682
+ var ck = api();
683
+ var list = null;
684
+ try {
685
+ if (ck && typeof ck._services === 'function') list = ck._services();
686
+ } catch (e) { list = null; }
687
+ if (!list || !Array.isArray(list)) return [];
688
+ var out = [];
689
+ for (var i = 0; i < list.length; i++) {
690
+ if (list[i] && list[i].category === cat) out.push(list[i]);
691
+ }
692
+ return out;
693
+ }
694
+
695
+ /* The service's one-line purpose, in the banner's language.
696
+
697
+ §3 asks for «одна строка purpose на языке баннера» and §2 ships
698
+ `purpose: { ru?, ro?, en? }`. Falls back to en, then to nothing at all —
699
+ an empty purpose renders no paragraph rather than the string "undefined"
700
+ or a stray language. LANG is the resolved banner code, so a `pt-BR`
701
+ banner asks for `pt` and lands on en, which is the honest answer. */
702
+ function servicePurpose(svc, lang) {
703
+ var p = (svc && svc.purpose) || {};
704
+ var code = String(lang == null ? LANG : lang || 'en').slice(0, 2).toLowerCase();
705
+ var v = p[code] || p.en;
706
+ return (typeof v === 'string' && v.trim()) ? v.trim() : '';
707
+ }
708
+
709
+ /* The cookieTable rows this service claims, by name.
710
+
711
+ §3: «под сервисом его cookie (имя · срок · назначение)» drawn «from
712
+ cookieTable rows whose name is in service.cookies». A name the service
713
+ declares but the table does not describe is NOT invented here: the panel
714
+ shows what the owner wrote down, and the declaration page is where the
715
+ full list lives. */
716
+ function cookieRowsForService(rows, svc) {
717
+ var names = (svc && svc.cookies) || [];
718
+ if (!names.length) return [];
719
+ var out = [];
720
+ for (var i = 0; i < rows.length; i++) {
721
+ var n = rows[i] && rows[i].name;
722
+ if (typeof n === 'string' && names.indexOf(n) > -1) out.push(rows[i]);
723
+ }
724
+ return out;
725
+ }
726
+
727
+ /* SPEC V1.12 §3 — «N сервисов · M cookie» on the group header. Pure, so the
728
+ wording in each of the three languages is testable without a DOM.
729
+
730
+ The cookie half counts the WHOLE group, services and loose rows alike: the
731
+ line answers «сколько cookie в этой группе», which is the question a
732
+ visitor scanning the header is actually asking. */
733
+ function groupCountLabel(serviceCount, cookieCount, strings, lang) {
734
+ return plural(strings.svcCount, serviceCount, lang) + ' · ' +
735
+ plural(strings.ckCount, cookieCount, lang);
736
+ }
737
+
738
+ /* Rows left over once every service has taken its own — «Cookie в этой
739
+ группе (N)» keeps meaning «the rest», exactly as it did before services
740
+ existed. With no services at all this returns the whole list unchanged. */
741
+ function looseCookies(rows, svcs) {
742
+ if (!svcs.length) return rows;
743
+ var claimed = {};
744
+ for (var i = 0; i < svcs.length; i++) {
745
+ var names = svcs[i].cookies || [];
746
+ for (var j = 0; j < names.length; j++) claimed[names[j]] = true;
747
+ }
748
+ var out = [];
749
+ for (var k = 0; k < rows.length; k++) {
750
+ var n = rows[k] && rows[k].name;
751
+ if (typeof n === 'string' && claimed[n]) continue;
752
+ out.push(rows[k]);
753
+ }
754
+ return out;
755
+ }
756
+
523
757
  /* ----------------------------------------------------------------- theme */
524
758
 
525
759
  // Built-in palettes. Dark values are picked for >= 4.5:1 text contrast.
@@ -1252,11 +1486,155 @@
1252
1486
  b.addEventListener('click', function () {
1253
1487
  var on = b.getAttribute('aria-checked') === 'true';
1254
1488
  b.setAttribute('aria-checked', on ? 'false' : 'true');
1489
+ /* SPEC V1.12 §3 — the group switch is the master:
1490
+ off -> every service of the group goes off and is blocked;
1491
+ on -> the services come back, EXCEPT the ones turned off by hand.
1492
+ The hand-set state is kept on the service switch itself (dataset.man)
1493
+ rather than being read back off the group, which is what lets a
1494
+ manual refusal survive the group being toggled off and on again. */
1495
+ syncGroup(cat);
1255
1496
  });
1256
1497
  }
1257
1498
  return b;
1258
1499
  }
1259
1500
 
1501
+ /* SPEC V1.12 §3 — one service, with its own switch, its purpose, its policy
1502
+ link and the cookies it sets. */
1503
+ function makeServiceSwitch(svc, cat) {
1504
+ var b = el('button', 'ck-switch ck-switch--sm');
1505
+ b.type = 'button';
1506
+ b.setAttribute('role', 'switch');
1507
+ b.setAttribute('aria-checked', 'false');
1508
+ b.dataset.svc = svc.id;
1509
+ b.dataset.cat = cat;
1510
+ // '1' once the visitor has switched this service off by hand. Read by
1511
+ // syncGroup() when the group comes back on, and cleared when they switch it
1512
+ // on again — a service the visitor re-enables is no longer «отключён вручную».
1513
+ b.dataset.man = '';
1514
+ /* A service switch is only ever clickable while its group is ON: syncGroup()
1515
+ disables it otherwise, and a disabled <button> fires no click. So this
1516
+ handler always runs with the group on, and there is no "turn the group
1517
+ back on too" case to handle — the visitor reaches a refused group through
1518
+ the group's own switch. */
1519
+ b.addEventListener('click', function () {
1520
+ var on = b.getAttribute('aria-checked') === 'true';
1521
+ b.setAttribute('aria-checked', on ? 'false' : 'true');
1522
+ // '1' = «switched off by hand». Cleared when it is switched back on, so a
1523
+ // re-enabled service is no longer «отключён вручную» and follows its group.
1524
+ b.dataset.man = on ? '1' : '';
1525
+ });
1526
+ return b;
1527
+ }
1528
+
1529
+ /* Pushes the group switch's state down onto its services. Called when the
1530
+ group is clicked and when the panel is synced from the stored state. */
1531
+ function syncGroup(cat) {
1532
+ var on = !!(switches[cat] && switches[cat].getAttribute('aria-checked') === 'true');
1533
+ var list = serviceSwitches[cat] || [];
1534
+ for (var i = 0; i < list.length; i++) {
1535
+ var b = list[i];
1536
+ // Group off: everything off. Group on: everything on except what the
1537
+ // visitor turned off by hand.
1538
+ var want = on && b.dataset.man !== '1';
1539
+ b.setAttribute('aria-checked', want ? 'true' : 'false');
1540
+ // A service cannot be granted while its group is refused, and a switch
1541
+ // that looks operable but changes nothing is worse than a disabled one.
1542
+ b.disabled = !on;
1543
+ if (!on) b.setAttribute('aria-disabled', 'true');
1544
+ else b.removeAttribute('aria-disabled');
1545
+ }
1546
+ }
1547
+
1548
+ function buildService(svc, cat, rows) {
1549
+ var wrap = el('div', 'ck-svc');
1550
+ var top = el('div', 'ck-svc__top');
1551
+ var txt = el('div', 'ck-svc__txt');
1552
+
1553
+ var nameId = 'ck-svc-' + svc.id;
1554
+ var name = el('div', 'ck-svc__name');
1555
+ var nameSpan = el('span', null, svc.name);
1556
+ nameSpan.id = nameId;
1557
+ name.appendChild(nameSpan);
1558
+ txt.appendChild(name);
1559
+
1560
+ if (svc.vendor) txt.appendChild(el('p', 'ck-svc__vendor', svc.vendor));
1561
+
1562
+ var descId = null;
1563
+ var purpose = servicePurpose(svc);
1564
+ if (purpose) {
1565
+ descId = nameId + '-desc';
1566
+ var p = el('p', 'ck-svc__desc', purpose);
1567
+ p.id = descId;
1568
+ txt.appendChild(p);
1569
+ }
1570
+
1571
+ /* «Политика» — target=_blank rel=noopener, per §3. The URL is already
1572
+ http(s)-validated by the core's normalizeService(), which is where a
1573
+ javascript: address is dropped; nothing unvalidated reaches an href. */
1574
+ if (svc.privacyUrl) {
1575
+ var a = el('a', 'ck-svc__policy', T.svcPolicy);
1576
+ a.href = svc.privacyUrl;
1577
+ a.target = '_blank';
1578
+ a.rel = 'noopener noreferrer';
1579
+ // The link text is the same word on every row, so a screen reader needs
1580
+ // the service name to tell them apart.
1581
+ a.setAttribute('aria-label', T.svcPolicy + ' — ' + svc.name);
1582
+ txt.appendChild(a);
1583
+ }
1584
+
1585
+ var sw = makeServiceSwitch(svc, cat);
1586
+ sw.setAttribute('aria-labelledby', nameId);
1587
+ if (descId) sw.setAttribute('aria-describedby', descId);
1588
+ if (!serviceSwitches[cat]) serviceSwitches[cat] = [];
1589
+ serviceSwitches[cat].push(sw);
1590
+
1591
+ top.appendChild(txt);
1592
+ top.appendChild(sw);
1593
+ wrap.appendChild(top);
1594
+
1595
+ var own = cookieRowsForService(rows, svc);
1596
+ if (own.length) wrap.appendChild(cookieTable(own, T.svcCookies + ' (' + own.length + ')'));
1597
+
1598
+ return wrap;
1599
+ }
1600
+
1601
+ /* The <details> block a group and a service both use for their cookies:
1602
+ same columns, same markup, one summary line apart. */
1603
+ function cookieTable(rows, summaryText) {
1604
+ var det = el('details', 'ck-det');
1605
+ var sum = el('summary');
1606
+ sum.appendChild(document.createTextNode(summaryText));
1607
+ det.appendChild(sum);
1608
+
1609
+ var tw = el('div', 'ck-tablewrap');
1610
+ var table = el('table');
1611
+ var thead = el('thead');
1612
+ var htr = el('tr');
1613
+ var heads = [T.colName, T.colVendor, T.colPurpose, T.colExpiry];
1614
+ for (var h = 0; h < heads.length; h++) {
1615
+ var th = el('th', null, heads[h]);
1616
+ th.setAttribute('scope', 'col');
1617
+ htr.appendChild(th);
1618
+ }
1619
+ thead.appendChild(htr);
1620
+ table.appendChild(thead);
1621
+
1622
+ var tbody = el('tbody');
1623
+ for (var r = 0; r < rows.length; r++) {
1624
+ var row = rows[r];
1625
+ var tr = el('tr');
1626
+ tr.appendChild(el('td', 'ck-mono', String(row.name == null ? '—' : row.name)));
1627
+ tr.appendChild(el('td', null, String(row.vendor == null ? '—' : row.vendor)));
1628
+ tr.appendChild(el('td', null, String(row.purpose == null ? '—' : row.purpose)));
1629
+ tr.appendChild(el('td', null, String(row.expiry == null ? '—' : row.expiry)));
1630
+ tbody.appendChild(tr);
1631
+ }
1632
+ table.appendChild(tbody);
1633
+ tw.appendChild(table);
1634
+ det.appendChild(tw);
1635
+ return det;
1636
+ }
1637
+
1260
1638
  function buildCategory(cfg, cat) {
1261
1639
  var meta = T.cat[cat] || { title: cat, desc: '' };
1262
1640
  var locked = cat === 'necessary';
@@ -1278,6 +1656,21 @@
1278
1656
  desc.id = descId;
1279
1657
  txt.appendChild(desc);
1280
1658
 
1659
+ var rows = cookiesFor(cfg, cat);
1660
+ var svcs = servicesFor(cat);
1661
+
1662
+ /* SPEC V1.12 §3 — «N сервисов · M cookie» on the group header.
1663
+
1664
+ Rendered ONLY when the group actually has services. A site whose config
1665
+ predates 0.5.8 has none, and must look exactly as it did in 0.5.7 — not
1666
+ «0 сервисов · 3 cookie». The cookie half counts the whole group, services
1667
+ and loose rows alike: it answers «сколько cookie в этой группе», which is
1668
+ the question the line is there to answer. */
1669
+ if (svcs.length) {
1670
+ name.appendChild(el('span', 'ck-cat__count',
1671
+ groupCountLabel(svcs.length, rows.length, T, LANG)));
1672
+ }
1673
+
1281
1674
  var sw = makeSwitch(cat, locked);
1282
1675
  sw.setAttribute('aria-labelledby', nameId);
1283
1676
  sw.setAttribute('aria-describedby', descId);
@@ -1287,40 +1680,24 @@
1287
1680
  top.appendChild(sw);
1288
1681
  wrap.appendChild(top);
1289
1682
 
1290
- var rows = cookiesFor(cfg, cat);
1291
- if (rows.length) {
1292
- var det = el('details', 'ck-det');
1293
- var sum = el('summary');
1294
- sum.appendChild(document.createTextNode(T.cookiesIn + ' (' + rows.length + ')'));
1295
- det.appendChild(sum);
1296
-
1297
- var tw = el('div', 'ck-tablewrap');
1298
- var table = el('table');
1299
- var thead = el('thead');
1300
- var htr = el('tr');
1301
- var heads = [T.colName, T.colVendor, T.colPurpose, T.colExpiry];
1302
- for (var h = 0; h < heads.length; h++) {
1303
- var th = el('th', null, heads[h]);
1304
- th.setAttribute('scope', 'col');
1305
- htr.appendChild(th);
1306
- }
1307
- thead.appendChild(htr);
1308
- table.appendChild(thead);
1309
-
1310
- var tbody = el('tbody');
1311
- for (var r = 0; r < rows.length; r++) {
1312
- var row = rows[r];
1313
- var tr = el('tr');
1314
- tr.appendChild(el('td', 'ck-mono', String(row.name == null ? '—' : row.name)));
1315
- tr.appendChild(el('td', null, String(row.vendor == null ? '—' : row.vendor)));
1316
- tr.appendChild(el('td', null, String(row.purpose == null ? '—' : row.purpose)));
1317
- tr.appendChild(el('td', null, String(row.expiry == null ? '—' : row.expiry)));
1318
- tbody.appendChild(tr);
1683
+ if (svcs.length) {
1684
+ var list = el('div', 'ck-svcs');
1685
+ // A list, so a screen reader announces «3 items» before reading them.
1686
+ list.setAttribute('role', 'list');
1687
+ for (var s = 0; s < svcs.length; s++) {
1688
+ var item = buildService(svcs[s], cat, rows);
1689
+ item.setAttribute('role', 'listitem');
1690
+ list.appendChild(item);
1319
1691
  }
1320
- table.appendChild(tbody);
1321
- tw.appendChild(table);
1322
- det.appendChild(tw);
1323
- wrap.appendChild(det);
1692
+ wrap.appendChild(list);
1693
+ }
1694
+
1695
+ // «Cookie в этой группе (N)» keeps its old meaning: what is left once each
1696
+ // service has claimed its own. With no services that is the whole table and
1697
+ // the summary line is byte-for-byte what 0.5.7 rendered.
1698
+ var loose = looseCookies(rows, svcs);
1699
+ if (loose.length) {
1700
+ wrap.appendChild(cookieTable(loose, T.cookiesIn + ' (' + loose.length + ')'));
1324
1701
  }
1325
1702
 
1326
1703
  return wrap;
@@ -1596,6 +1973,32 @@
1596
1973
  var sw = switches[k];
1597
1974
  out[k] = !!(sw && sw.getAttribute('aria-checked') === 'true');
1598
1975
  }
1976
+ /* SPEC V1.12 §3 — «хранение: только отказы».
1977
+
1978
+ Read from `dataset.man`, NOT from `aria-checked`. The two answer different
1979
+ questions and only one of them is the visitor's decision:
1980
+
1981
+ aria-checked — what the switch currently SHOWS. syncGroup() forces every
1982
+ service of a refused group to `false`, so reading this
1983
+ would record «I refused all six of these» the moment the
1984
+ group went off.
1985
+ dataset.man — «the visitor switched this one off by hand». Set on click,
1986
+ restored from the stored record, and deliberately left
1987
+ alone by syncGroup().
1988
+
1989
+ Getting this backwards loses a denial: refuse Hotjar, later switch
1990
+ analytics off and save, and the refusal would be erased — so when
1991
+ analytics came back on, Hotjar would run again without ever having been
1992
+ re-consented to. */
1993
+ var svcs = {};
1994
+ for (var c = 0; c < OPT_IN.length; c++) {
1995
+ var list = serviceSwitches[OPT_IN[c]] || [];
1996
+ for (var j = 0; j < list.length; j++) {
1997
+ var b = list[j];
1998
+ if (b.dataset.man === '1') svcs[b.dataset.svc] = false;
1999
+ }
2000
+ }
2001
+ out.services = svcs;
1599
2002
  return out;
1600
2003
  }
1601
2004
 
@@ -1674,6 +2077,23 @@
1674
2077
  sw.setAttribute('aria-checked', on ? 'true' : 'false');
1675
2078
  }
1676
2079
  if (switches.necessary) switches.necessary.setAttribute('aria-checked', 'true');
2080
+
2081
+ /* SPEC V1.12 §3 — restore the per-service switches from the stored denials.
2082
+
2083
+ `dataset.man` is set from the record FIRST, then syncGroup() derives what
2084
+ each switch shows from it. That ordering is the whole point: a visitor who
2085
+ denied Hotjar and left analytics off must still see Hotjar's own switch
2086
+ off when they turn analytics back on, and the group→service push is what
2087
+ would otherwise light it up again. */
2088
+ var denials = (state && state.services) || {};
2089
+ for (var c = 0; c < OPT_IN.length; c++) {
2090
+ var cat = OPT_IN[c];
2091
+ var list = serviceSwitches[cat] || [];
2092
+ for (var j = 0; j < list.length; j++) {
2093
+ list[j].dataset.man = denials[list[j].dataset.svc] === false ? '1' : '';
2094
+ }
2095
+ syncGroup(cat);
2096
+ }
1677
2097
  }
1678
2098
 
1679
2099
  // Idempotent: safe to call from ck:init, ck:change and right after our own API calls.
@@ -1852,8 +2272,13 @@
1852
2272
  icon.innerHTML = PLAY_ICON;
1853
2273
  card.appendChild(icon);
1854
2274
 
1855
- var label = hostOf(frame.getAttribute('data-src'));
1856
- card.appendChild(el('p', null, placeholderText(label, cat, LANG)));
2275
+ var frameSrcAttr = frame.getAttribute('data-src') || '';
2276
+ var label = hostOf(frameSrcAttr);
2277
+ // §3: when a refused SERVICE is what is holding this frame, the sentence
2278
+ // must name that service — its category is already granted.
2279
+ var held = holdingService(frameSrcAttr);
2280
+ card.appendChild(el('p', null,
2281
+ placeholderText(label, cat, LANG, held ? held.name : null)));
1857
2282
 
1858
2283
  var row = el('div', 'ck-ph__row');
1859
2284
  /* Only offer the grant when it can actually take effect: the core's
@@ -1863,7 +2288,10 @@
1863
2288
  if (categoryEnabled(cfg, cat)) {
1864
2289
  var allow = el('button', 'ck-ph__btn', T.phAllow);
1865
2290
  allow.type = 'button';
1866
- allow.addEventListener('click', function () { grantCategory(cat); });
2291
+ // SPEC V1.12 §3: «кнопка "Разрешить и показать" включает категорию и
2292
+ // снимает отказ по этому сервису». The frame's own address decides which
2293
+ // service that is — the visitor pressed the button on THIS embed.
2294
+ allow.addEventListener('click', function () { grantCategory(cat, frameSrcAttr); });
1867
2295
  row.appendChild(allow);
1868
2296
  }
1869
2297
  var settings = el('button', 'ck-ph__link', T.phSettings);
@@ -1888,20 +2316,55 @@
1888
2316
  the core dispatches — so there is no logging code here. The core's
1889
2317
  applyConsentToDom() restores the frame; sweepPlaceholders() then removes
1890
2318
  this card, driven by the ck:change that same commit dispatches. */
1891
- function grantCategory(cat) {
2319
+ function grantCategory(cat, src) {
1892
2320
  var ck = api();
1893
2321
  if (!ck || typeof ck.accept !== 'function') return;
1894
- var cur = safeState().categories || {};
2322
+ var st = safeState();
2323
+ var cur = st.categories || {};
1895
2324
  var next = {
1896
2325
  functional: cur.functional === true,
1897
2326
  analytics: cur.analytics === true,
1898
2327
  marketing: cur.marketing === true
1899
2328
  };
1900
2329
  if (cat === 'functional' || cat === 'analytics' || cat === 'marketing') next[cat] = true;
2330
+
2331
+ /* SPEC V1.12 §3 — clear the refusal on THIS frame's service, in the SAME
2332
+ accept() call as the category grant.
2333
+
2334
+ One call, not two: the core revives blocked frames inside commit(), so a
2335
+ denial still standing at that moment leaves this frame dead until some
2336
+ later consent change happens to run applyConsentToDom() again. The whole
2337
+ map is passed because accept() replaces it wholesale — every OTHER
2338
+ refusal the visitor made is copied across untouched. */
2339
+ var denials = denialsWithout(st.services, src);
2340
+ if (denials) next.services = denials;
2341
+
1901
2342
  try { ck.accept(next); } catch (e) { /* noop */ }
1902
2343
  syncFromState();
1903
2344
  }
1904
2345
 
2346
+ /* The stored denial map minus the service that owns `src`, or null when
2347
+ nothing would change (no denials, no service for that URL, or that service
2348
+ was not refused in the first place). */
2349
+ function denialsWithout(stored, src) {
2350
+ var ck = api();
2351
+ if (!ck || typeof ck._serviceForUrl !== 'function') return null;
2352
+ var map = {};
2353
+ var any = false;
2354
+ try {
2355
+ Object.keys(stored || {}).forEach(function (k) {
2356
+ if (stored[k] === false) { map[k] = false; any = true; }
2357
+ });
2358
+ } catch (e) { return null; }
2359
+ if (!any) return null;
2360
+
2361
+ var svc = null;
2362
+ try { svc = src ? ck._serviceForUrl(src) : null; } catch (e2) { svc = null; }
2363
+ if (!svc || map[svc.id] !== false) return null;
2364
+ delete map[svc.id];
2365
+ return map;
2366
+ }
2367
+
1905
2368
  function hideFrame(frame) {
1906
2369
  try {
1907
2370
  frame.setAttribute(PH_DISPLAY, frame.style.display || '');
@@ -1992,10 +2455,38 @@
1992
2455
  // and mount() is one-shot. Buttons and colours are deliberately NOT
1993
2456
  // here — they are token values and restyle in place.
1994
2457
  resolveDetails(c).kind,
2458
+ /* SPEC V1.12 §3 — services are STRUCTURAL: each one adds a row with its
2459
+ own switch to the panel, and mount() is one-shot. Without this a SaaS
2460
+ config that arrives after the first mount (the second, idempotent
2461
+ init()) would re-run buildServices() in the core — so the engine would
2462
+ block per service — while the panel kept showing the service-less
2463
+ render, and the visitor would have no way to see or change any of it.
2464
+
2465
+ The COUNT and the ids, not the whole rows: what needs a rebuild is a
2466
+ service appearing, disappearing or changing identity. A reworded
2467
+ `purpose` is text inside an existing row and does not justify tearing
2468
+ the panel down. */
2469
+ serviceSignature(c),
1995
2470
  brandSignature(c)
1996
2471
  ].join('|');
1997
2472
  }
1998
2473
 
2474
+ function serviceSignature(cfg) {
2475
+ var list = cfg && cfg.services;
2476
+ if (!list || !Array.isArray(list) || !list.length) return '0';
2477
+ var ids = [];
2478
+ for (var i = 0; i < list.length; i++) {
2479
+ var s = list[i];
2480
+ if (!s || typeof s !== 'object' || s.enabled === false) continue;
2481
+ ids.push(String(s.id || '') + ':' + String(s.category || ''));
2482
+ }
2483
+ // '0' for «no services», however that came about: an absent key, an empty
2484
+ // array, or a list every row of which the core would drop. All three render
2485
+ // the same panel, so none of them may differ in the signature.
2486
+ if (!ids.length) return '0';
2487
+ return ids.length + ',' + ids.join(',');
2488
+ }
2489
+
1999
2490
  function remount(cfg) {
2000
2491
  mounted = false;
2001
2492
  panelOpen = false;
@@ -2041,6 +2532,7 @@
2041
2532
  root.appendChild(style);
2042
2533
 
2043
2534
  switches = {};
2535
+ serviceSwitches = {};
2044
2536
  nodes = {};
2045
2537
 
2046
2538
  // Palette sheet comes after the base sheet so its :host tokens win.
@@ -2142,7 +2634,22 @@
2142
2634
  // SPEC V1.10 §2: which sentence a blocked embed shows. Pure, so the
2143
2635
  // wording is testable (and quotable by the cabinet) without a DOM.
2144
2636
  placeholderText: placeholderText,
2145
- placeholdersEnabled: placeholdersEnabled
2637
+ placeholdersEnabled: placeholdersEnabled,
2638
+ /* SPEC V1.12 §3 — the pure halves of the services panel, so the wording
2639
+ and the arithmetic are testable (and quotable by the cabinet) without a
2640
+ DOM: which plural form a count takes in each language, how a group
2641
+ header reads, and which cookieTable rows sit under which service. */
2642
+ plural: plural,
2643
+ pluralIndex: pluralIndex,
2644
+ buildStrings: buildStrings,
2645
+ localeTable: localeTable,
2646
+ resolveLang: resolveLang,
2647
+ cookieRowsForService: cookieRowsForService,
2648
+ looseCookies: looseCookies,
2649
+ servicePurpose: servicePurpose,
2650
+ groupCountLabel: groupCountLabel,
2651
+ serviceSignature: serviceSignature,
2652
+ signature: signature
2146
2653
  };
2147
2654
  // The page-font probe reads the DOM, so it is not part of the pure block —
2148
2655
  // but the debug panel must be able to quote the family the banner painted