@ecomconsult/consentkit 0.5.7 → 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,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
@@ -342,6 +458,28 @@
342
458
  '.ck-cat__badge{font-size:12px;font-weight:500;color:var(--ck-muted);',
343
459
  'border:1px solid var(--ck-line);border-radius:999px;padding:1px 8px}',
344
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)}',
345
483
 
346
484
  /* ---- switch ---- */
347
485
  '.ck-switch{flex:none;width:46px;height:27px;padding:0;border-radius:999px;',
@@ -351,6 +489,11 @@
351
489
  '.ck-switch[aria-checked="true"]{background:var(--ck-accent);border-color:var(--ck-accent)}',
352
490
  '.ck-switch[aria-checked="true"]::after{left:auto;right:2px;border-color:transparent}',
353
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}',
354
497
 
355
498
  /* ---- cookie table ---- */
356
499
  '.ck-det{margin-top:12px}',
@@ -491,6 +634,7 @@
491
634
  var LANG = 'en'; // the code T was built from, reassigned per mount
492
635
  var nodes = {}; // banner/panel/fab refs
493
636
  var switches = {}; // category -> button
637
+ var serviceSwitches = {}; // category -> [button], SPEC V1.12 §3
494
638
  var panelOpen = false;
495
639
  var lastFocus = null;
496
640
 
@@ -520,6 +664,92 @@
520
664
  return out;
521
665
  }
522
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
+
523
753
  /* ----------------------------------------------------------------- theme */
524
754
 
525
755
  // Built-in palettes. Dark values are picked for >= 4.5:1 text contrast.
@@ -1252,11 +1482,155 @@
1252
1482
  b.addEventListener('click', function () {
1253
1483
  var on = b.getAttribute('aria-checked') === 'true';
1254
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);
1255
1492
  });
1256
1493
  }
1257
1494
  return b;
1258
1495
  }
1259
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
+
1260
1634
  function buildCategory(cfg, cat) {
1261
1635
  var meta = T.cat[cat] || { title: cat, desc: '' };
1262
1636
  var locked = cat === 'necessary';
@@ -1278,6 +1652,21 @@
1278
1652
  desc.id = descId;
1279
1653
  txt.appendChild(desc);
1280
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
+
1281
1670
  var sw = makeSwitch(cat, locked);
1282
1671
  sw.setAttribute('aria-labelledby', nameId);
1283
1672
  sw.setAttribute('aria-describedby', descId);
@@ -1287,40 +1676,24 @@
1287
1676
  top.appendChild(sw);
1288
1677
  wrap.appendChild(top);
1289
1678
 
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);
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);
1319
1687
  }
1320
- table.appendChild(tbody);
1321
- tw.appendChild(table);
1322
- det.appendChild(tw);
1323
- 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 + ')'));
1324
1697
  }
1325
1698
 
1326
1699
  return wrap;
@@ -1596,6 +1969,32 @@
1596
1969
  var sw = switches[k];
1597
1970
  out[k] = !!(sw && sw.getAttribute('aria-checked') === 'true');
1598
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;
1599
1998
  return out;
1600
1999
  }
1601
2000
 
@@ -1674,6 +2073,23 @@
1674
2073
  sw.setAttribute('aria-checked', on ? 'true' : 'false');
1675
2074
  }
1676
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
+ }
1677
2093
  }
1678
2094
 
1679
2095
  // Idempotent: safe to call from ck:init, ck:change and right after our own API calls.
@@ -1852,8 +2268,13 @@
1852
2268
  icon.innerHTML = PLAY_ICON;
1853
2269
  card.appendChild(icon);
1854
2270
 
1855
- var label = hostOf(frame.getAttribute('data-src'));
1856
- card.appendChild(el('p', null, placeholderText(label, cat, LANG)));
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)));
1857
2278
 
1858
2279
  var row = el('div', 'ck-ph__row');
1859
2280
  /* Only offer the grant when it can actually take effect: the core's
@@ -1863,7 +2284,10 @@
1863
2284
  if (categoryEnabled(cfg, cat)) {
1864
2285
  var allow = el('button', 'ck-ph__btn', T.phAllow);
1865
2286
  allow.type = 'button';
1866
- allow.addEventListener('click', function () { grantCategory(cat); });
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); });
1867
2291
  row.appendChild(allow);
1868
2292
  }
1869
2293
  var settings = el('button', 'ck-ph__link', T.phSettings);
@@ -1888,20 +2312,55 @@
1888
2312
  the core dispatches — so there is no logging code here. The core's
1889
2313
  applyConsentToDom() restores the frame; sweepPlaceholders() then removes
1890
2314
  this card, driven by the ck:change that same commit dispatches. */
1891
- function grantCategory(cat) {
2315
+ function grantCategory(cat, src) {
1892
2316
  var ck = api();
1893
2317
  if (!ck || typeof ck.accept !== 'function') return;
1894
- var cur = safeState().categories || {};
2318
+ var st = safeState();
2319
+ var cur = st.categories || {};
1895
2320
  var next = {
1896
2321
  functional: cur.functional === true,
1897
2322
  analytics: cur.analytics === true,
1898
2323
  marketing: cur.marketing === true
1899
2324
  };
1900
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
+
1901
2338
  try { ck.accept(next); } catch (e) { /* noop */ }
1902
2339
  syncFromState();
1903
2340
  }
1904
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
+
1905
2364
  function hideFrame(frame) {
1906
2365
  try {
1907
2366
  frame.setAttribute(PH_DISPLAY, frame.style.display || '');
@@ -1992,10 +2451,38 @@
1992
2451
  // and mount() is one-shot. Buttons and colours are deliberately NOT
1993
2452
  // here — they are token values and restyle in place.
1994
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),
1995
2466
  brandSignature(c)
1996
2467
  ].join('|');
1997
2468
  }
1998
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
+
1999
2486
  function remount(cfg) {
2000
2487
  mounted = false;
2001
2488
  panelOpen = false;
@@ -2041,6 +2528,7 @@
2041
2528
  root.appendChild(style);
2042
2529
 
2043
2530
  switches = {};
2531
+ serviceSwitches = {};
2044
2532
  nodes = {};
2045
2533
 
2046
2534
  // Palette sheet comes after the base sheet so its :host tokens win.
@@ -2142,7 +2630,22 @@
2142
2630
  // SPEC V1.10 §2: which sentence a blocked embed shows. Pure, so the
2143
2631
  // wording is testable (and quotable by the cabinet) without a DOM.
2144
2632
  placeholderText: placeholderText,
2145
- placeholdersEnabled: placeholdersEnabled
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
2146
2649
  };
2147
2650
  // The page-font probe reads the DOM, so it is not part of the pure block —
2148
2651
  // but the debug panel must be able to quote the family the banner painted