@ecomconsult/consentkit 0.5.6 → 0.5.7

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/README.md CHANGED
@@ -188,13 +188,15 @@ Pass any subset to `init()`. Nested objects merge with the defaults.
188
188
  | `theme.mode` | `"auto" \| "light" \| "dark"` | `"auto"` | `auto` follows `prefers-color-scheme` |
189
189
  | `theme.dark` | `{ bg, ink, accent, onAccent }` | built-in | Overrides the dark palette |
190
190
  | `texts.policyUrl` | `string` | — | v0.5.0. Cookie policy address. `http(s)` only; anything else is ignored |
191
- | `texts.detailsAction` | `"policy" \| "settings" \| "hide"` | see notes | v0.5.0. What «Learn more» does. Defaults to `policy` when `policyUrl` is set, `settings` when it is not. `policy` without a usable URL falls back to `settings` rather than rendering a dead link |
191
+ | `texts.detailsAction` | `"policy" \| "settings" \| "hide" \| "declaration"` | see notes | v0.5.0, `declaration` in v0.5.7. What «Learn more» does. Defaults to `policy` when `policyUrl` is set, `settings` when it is not. `policy` or `declaration` without a usable URL falls back to `settings` rather than rendering a dead link |
192
+ | `texts.declarationUrl` | `string` | — | v0.5.7. Address of the cookie declaration page, used by `detailsAction: "declaration"`. `http(s)` only. Filled by the hosted service; the client only reads it |
192
193
  | `categories.*.enabled` | `boolean` | `true` | Per category: `functional`, `analytics`, `marketing`. Hides the toggle when `false` |
193
194
  | `consentTtlDays` | `number` | `365` | Lifetime of the stored decision |
194
195
  | `integrations.gcm` | `boolean` | `true` | Google Consent Mode v2 signals |
195
196
  | `integrations.gtmDataLayer` | `boolean` | `true` | Push consent events to `window.dataLayer` |
196
197
  | `blocking.mode` | `"known" \| "strict"` | `"known"` | `strict` also holds back unknown third-party scripts and iframes — see [Strict mode](#strict-mode) |
197
198
  | `blocking.allow` | `string[]` | `[]` | Hosts strict mode must never intercept. Matched by suffix, so `partner.com` also covers `cdn.partner.com` |
199
+ | `blocking.placeholders` | `boolean` | `true` | v0.5.7. Draw a card in place of an embed held back before consent — see [Placeholders for blocked embeds](#placeholders-for-blocked-embeds). `false` restores the pre-0.5.7 behaviour: the frame is still blocked, just invisible |
198
200
  | `hostdb` | `Record<string, Category>` | — | Extra `host: category` pairs merged into the tracker database, applied before the initial scan. SaaS mode fills this from the service; `ConsentKit._extendHostDb()` does the same at any later point |
199
201
  | `cookieTable` | `CkCookieTableEntry[]` | `[]` | Declared cookies, listed per category in the panel |
200
202
 
@@ -267,14 +269,69 @@ resolved colours, its contrast ratio, and whether the value was adjusted.
267
269
  | `"policy"` | A link to `texts.policyUrl`, opened with `target="_blank" rel="noopener"` |
268
270
  | `"settings"` | A button that opens the preferences panel |
269
271
  | `"hide"` | Nothing at all |
272
+ | `"declaration"` | v0.5.7. A link to `texts.declarationUrl` — the cookie declaration page — opened the same way as `policy` |
270
273
 
271
274
  The default follows `policyUrl`: `policy` when one is set, `settings` when it
272
- is not — so supplying only a URL does the obvious thing.
275
+ is not — so supplying only a URL does the obvious thing. `declarationUrl`
276
+ deliberately does *not* affect that default: a site that gains a declaration
277
+ address keeps whatever «Learn more» already did until it asks for the change.
278
+
279
+ Both link forms accept `http(s)` addresses only. A `javascript:` or `data:` URL
280
+ in a control the visitor is invited to click is an XSS vector, so anything else
281
+ is refused and the link degrades to `settings`.
273
282
 
274
283
  > Before 0.5.0 this control was rendered as `<a href="#">` with no handler at
275
284
  > all: clicking it jumped to the top of the page and nothing else. Any site
276
285
  > running 0.4.x or earlier has a dead «Learn more» link.
277
286
 
287
+ ### Reopening the settings
288
+
289
+ `ConsentKit.openSettings()` opens the preferences panel from anywhere on the
290
+ page — a footer link, a menu item, a button in your own cookie policy. It is
291
+ safe to call before the banner has mounted: a call that arrives while the UI
292
+ file is still loading is remembered and honoured on mount, so a link clicked
293
+ during a slow page load still works.
294
+
295
+ The same panel has an address. Ссылка «Изменить выбор cookie» →
296
+ `https://site/#ck-settings`: любая ссылка на страницу сайта с этим хвостом
297
+ открывает окно настроек — и при загрузке страницы, и при переходе по ссылке на
298
+ уже открытой странице. Хвост убирается из адреса через `history.replaceState`,
299
+ поэтому перезагрузка или «назад» не открывают окно повторно. Это тот адрес, на
300
+ который ведёт кнопка «Изменить выбор cookie» на странице декларации cookie, и
301
+ его же удобно поставить в подвал сайта:
302
+
303
+ ```html
304
+ <a href="#ck-settings">Изменить выбор cookie</a>
305
+ ```
306
+
307
+ ### Placeholders for blocked embeds
308
+
309
+ When the engine holds back an `<iframe>` before consent — a known tracker, or
310
+ any third-party frame in strict mode — the visitor would otherwise see an empty
311
+ hole where a video or a map should be. Since 0.5.7 ConsentKit draws a card in
312
+ its place: the name of the service, the category the embed is waiting for, a
313
+ primary button «Разрешить и показать» that grants **that one category** and
314
+ loads the embed, and a link to the full settings panel.
315
+
316
+ The card is sized from the frame's own `width`/`height` (or its computed size),
317
+ never shorter than 120px, and never wider than its container. It is rendered in
318
+ its own Shadow DOM and takes the banner's theme — the page's font, your accent
319
+ colour and corner radius — so it looks like part of the site rather than part of
320
+ a third-party widget. Strings ship in ru, ro and en; every other language falls
321
+ back to en.
322
+
323
+ The button grants one category through the ordinary consent path: the decision
324
+ is stored and journalled as `method: 'custom'`, the usual `ck:consent` /
325
+ `ck:change` events fire, and consent the visitor had already given to *other*
326
+ categories is preserved rather than overwritten. The frame itself is restored by
327
+ the core's normal revival pass, which is the same code path the panel's switches
328
+ and «Accept all» already use.
329
+
330
+ Frames that are `display:none`, 1×1 tracking pixels, or outside `<body>` are
331
+ left alone, and a frame the site allowed never gets a card at all — an allowed
332
+ frame is never intercepted in the first place. Set `blocking.placeholders:
333
+ false` to restore the pre-0.5.7 behaviour.
334
+
278
335
  ### Infrastructure
279
336
 
280
337
  Some third-party hosts are not a consent decision at all: they are where a site
@@ -310,6 +367,7 @@ All methods are safe to call at any time and never throw.
310
367
  | `rejectAll()` | `CkState` | Denies every opt-in category. `method: 'reject_all'` |
311
368
  | `withdraw()` | `CkState` | Clears storage and known cookies, sends GCM `denied`, resets to `decided: false` |
312
369
  | `show()` | `void` | Opens the preferences panel |
370
+ | `openSettings()` | `void` | v0.5.7. Opens the preferences panel. Safe before the UI has loaded — the request is remembered and honoured as soon as the banner mounts |
313
371
  | `hide()` | `void` | Closes the panel |
314
372
  | `config` | `CkConfig` | The merged, effective config |
315
373
  | `version` | `string` | Core version string |
package/npm/core.cjs CHANGED
@@ -30,6 +30,7 @@ function createStub() {
30
30
  rejectAll: function () { return undecidedState(); },
31
31
  withdraw: function () { return undecidedState(); },
32
32
  show: function () {},
33
+ openSettings: function () {},
33
34
  hide: function () {},
34
35
  _categories: CATEGORIES.slice(),
35
36
  _isStub: true
package/npm/core.mjs CHANGED
@@ -28,6 +28,9 @@ export const {
28
28
  rejectAll,
29
29
  withdraw,
30
30
  show,
31
+ // v0.5.7: the public name for «open the cookie settings», safe to call
32
+ // before the UI has loaded.
33
+ openSettings,
31
34
  hide,
32
35
  // v0.4.0 (§1.3): merging service overrides into the tracker database is part
33
36
  // of the public surface, so it must be reachable as a named import too.
package/npm/index.cjs CHANGED
@@ -33,6 +33,7 @@ function createStub() {
33
33
  rejectAll: function () { return undecidedState(); },
34
34
  withdraw: function () { return undecidedState(); },
35
35
  show: function () {},
36
+ openSettings: function () {},
36
37
  hide: function () {},
37
38
  _categories: CATEGORIES.slice(),
38
39
  _isStub: true
package/npm/index.d.ts CHANGED
@@ -136,8 +136,17 @@ export interface CkTextsConfig {
136
136
  * What «Learn more» does. Defaults to `'policy'` when `policyUrl` is set and
137
137
  * `'settings'` when it is not. `'policy'` without a usable URL falls back to
138
138
  * `'settings'` rather than rendering a dead link. `'hide'` renders nothing.
139
+ * v0.5.7 adds `'declaration'`: a link to {@link CkTextsConfig.declarationUrl},
140
+ * the cookie declaration page, which likewise degrades to `'settings'`
141
+ * without a usable URL.
139
142
  */
140
- detailsAction?: 'policy' | 'settings' | 'hide';
143
+ detailsAction?: 'policy' | 'settings' | 'hide' | 'declaration';
144
+ /**
145
+ * v0.5.7. Address of the cookie declaration page, used by
146
+ * `detailsAction: 'declaration'`. `http(s)` only. Supplied by the hosted
147
+ * service; the client only reads it.
148
+ */
149
+ declarationUrl?: string;
141
150
  }
142
151
 
143
152
  /** Whether a category is offered in the preferences panel at all. */
@@ -170,6 +179,12 @@ export interface CkBlockingConfig {
170
179
  mode?: 'known' | 'strict';
171
180
  /** Hosts strict mode must never intercept. Suffix match: `p.com` covers `cdn.p.com`. */
172
181
  allow?: string[];
182
+ /**
183
+ * v0.5.7. Draw a card in place of an `<iframe>` held back before consent,
184
+ * offering «Разрешить и показать» for that one category. Default `true`;
185
+ * `false` restores the pre-0.5.7 behaviour (still blocked, just invisible).
186
+ */
187
+ placeholders?: boolean;
173
188
  }
174
189
 
175
190
  /** One declared cookie, shown under its category in the preferences panel. */
@@ -234,6 +249,12 @@ export interface ConsentKitApi {
234
249
  withdraw(): CkState;
235
250
  /** Dispatches `ck:ui:open-preferences`. */
236
251
  show(): void;
252
+ /**
253
+ * v0.5.7. Opens the preferences panel. Unlike {@link show}, a call made
254
+ * before the UI has loaded is remembered and honoured once the banner
255
+ * mounts, so a footer link clicked during a slow load still works.
256
+ */
257
+ openSettings(): void;
237
258
  /** Dispatches `ck:ui:close`. */
238
259
  hide(): void;
239
260
 
@@ -380,6 +401,7 @@ export declare function accept(choice?: CkAcceptArg): CkState;
380
401
  export declare function rejectAll(): CkState;
381
402
  export declare function withdraw(): CkState;
382
403
  export declare function show(): void;
404
+ export declare function openSettings(): void;
383
405
  export declare function hide(): void;
384
406
  export declare function _extendHostDb(map: Record<string, CkCategory>): number;
385
407
 
@@ -410,6 +432,7 @@ declare module '@ecomconsult/consentkit' {
410
432
  export function rejectAll(): CkState;
411
433
  export function withdraw(): CkState;
412
434
  export function show(): void;
435
+ export function openSettings(): void;
413
436
  export function hide(): void;
414
437
  }
415
438
 
@@ -424,6 +447,7 @@ declare module '@ecomconsult/consentkit/core' {
424
447
  export function rejectAll(): CkState;
425
448
  export function withdraw(): CkState;
426
449
  export function show(): void;
450
+ export function openSettings(): void;
427
451
  export function hide(): void;
428
452
  }
429
453
 
package/npm/index.mjs CHANGED
@@ -51,6 +51,9 @@ export const {
51
51
  rejectAll,
52
52
  withdraw,
53
53
  show,
54
+ // v0.5.7: the public name for «open the cookie settings», safe to call
55
+ // before the UI has loaded.
56
+ openSettings,
54
57
  hide,
55
58
  // v0.4.0 (§1.3): merging service overrides into the tracker database is part
56
59
  // of the public surface, so it must be reachable as a named import too.
@@ -28,7 +28,7 @@ export function undecidedState() {
28
28
  */
29
29
  export function createStub() {
30
30
  const stub = {
31
- version: '0.5.6',
31
+ version: '0.5.7',
32
32
  config: {},
33
33
  init: function () { return undecidedState(); },
34
34
  allowed: function (cat) { return cat === 'necessary'; },
@@ -37,6 +37,7 @@ export function createStub() {
37
37
  rejectAll: function () { return undecidedState(); },
38
38
  withdraw: function () { return undecidedState(); },
39
39
  show: function () {},
40
+ openSettings: function () {},
40
41
  hide: function () {},
41
42
  _categories: CATEGORIES.slice(),
42
43
  // v0.4.0: the stub mirrors the real surface, so consumer code that calls
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecomconsult/consentkit",
3
- "version": "0.5.6",
3
+ "version": "0.5.7",
4
4
  "description": "GDPR cookie consent core with blocking engine, Shadow DOM UI and Google Consent Mode v2. Zero dependencies, no build step.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/ck-core.js CHANGED
@@ -1537,7 +1537,7 @@
1537
1537
  // Public API
1538
1538
  // ---------------------------------------------------------------------------
1539
1539
  var ConsentKit = {
1540
- version: '0.5.6',
1540
+ version: '0.5.7',
1541
1541
  config: config,
1542
1542
 
1543
1543
  init: function (userConfig) {
@@ -1597,6 +1597,28 @@
1597
1597
  show: function () { dispatch('ck:ui:open-preferences', { state: publicState(), config: config }); },
1598
1598
  hide: function () { dispatch('ck:ui:close', { state: publicState() }); },
1599
1599
 
1600
+ /* SPEC V1.10 §1 — the public name for «открыть настройки cookie». A site
1601
+ puts it behind a footer link, and the declaration page links to
1602
+ `https://site/#ck-settings`, which ck-ui turns into the same call.
1603
+
1604
+ Deliberately not just an alias of show(): the event is a broadcast, and a
1605
+ page that calls openSettings() before ck-ui.js has been PARSED has no
1606
+ listener to receive it — the request would be silently lost, which is the
1607
+ one case a footer link hits (a click during a slow load). So the request
1608
+ is latched here as well as dispatched, and ck-ui's mount() consumes the
1609
+ latch at the end of its first render. ck-ui already writes to this object
1610
+ (_contrast, _resolvePageFont), so the coupling direction is established.
1611
+
1612
+ `_pendingOpen` is read-and-cleared by ck-ui; nothing else touches it. */
1613
+ openSettings: function () {
1614
+ try { ConsentKit._pendingOpen = true; } catch (e) { /* noop */ }
1615
+ dispatch('ck:ui:open-preferences', { state: publicState(), config: config });
1616
+ },
1617
+
1618
+ // Set by openSettings() when the UI may not be listening yet; cleared by
1619
+ // ck-ui.js the moment it can honour it. Underscored: not a public API.
1620
+ _pendingOpen: false,
1621
+
1600
1622
  // Introspection helpers for the demo status panel (read-only).
1601
1623
  _categoryForUrl: categoryForUrl,
1602
1624
  _categories: CATEGORIES.slice(),
package/src/ck-locales.js CHANGED
@@ -572,6 +572,12 @@
572
572
  colPurpose: 'Pentru ce',
573
573
  colExpiry: 'Expiră',
574
574
  floating: 'Setări cookie-uri',
575
+ // SPEC V1.10 §2 — заглушка встраивания. Only ro carries these here: en and
576
+ // ru are builtin in ck-ui.js, and every other locale falls back to en.
577
+ phText: 'Aici este conținut de la {host}. Se va încărca după acordul pentru «{cat}».',
578
+ phAllow: 'Permite și arată',
579
+ phSettings: 'Setări cookie-uri',
580
+ phLabel: 'Conținut blocat',
575
581
  cat: {
576
582
  necessary: { title: 'Necesare', desc: 'Sunt necesare pentru funcționarea site-ului: autentificare, securitate, reținerea alegerii dumneavoastră. Nu pot fi dezactivate.' },
577
583
  functional: { title: 'Funcționale', desc: 'Rețin alegerile dumneavoastră: limba, coșul, chatul.' },
package/src/ck-ui.js CHANGED
@@ -32,6 +32,14 @@
32
32
  colExpiry: 'Expires',
33
33
  floating: 'Cookie settings',
34
34
  poweredBy: 'Powered by ConsentKit',
35
+ // SPEC V1.10 §2 — the blocked-embed placeholder. {host} is the vendor
36
+ // label when the database knows one and the bare host otherwise; {cat} is
37
+ // the localized category title, taken from cat.<name>.title below, so the
38
+ // name in the placeholder is the name on the switch in the panel.
39
+ phText: 'Content from {host} goes here. It will load once you allow «{cat}».',
40
+ phAllow: 'Allow and show',
41
+ phSettings: 'Cookie settings',
42
+ phLabel: 'Blocked content',
35
43
  cat: {
36
44
  necessary: {
37
45
  title: 'Necessary',
@@ -72,6 +80,10 @@
72
80
  colExpiry: 'Срок',
73
81
  floating: 'Настройки cookie',
74
82
  poweredBy: 'Работает на ConsentKit',
83
+ phText: 'Здесь содержимое от {host}. Оно загрузится после согласия на «{cat}».',
84
+ phAllow: 'Разрешить и показать',
85
+ phSettings: 'Настроить cookie',
86
+ phLabel: 'Заблокированное содержимое',
75
87
  cat: {
76
88
  necessary: {
77
89
  title: 'Необходимые',
@@ -103,7 +115,11 @@
103
115
  'bannerTitle', 'bannerText', 'more', 'acceptAll', 'rejectAll', 'customize',
104
116
  'bannerLabel', 'panelTitle', 'panelIntro', 'save', 'close', 'alwaysOn',
105
117
  'cookiesIn', 'noCookies', 'colName', 'colVendor', 'colPurpose', 'colExpiry', 'floating',
106
- 'poweredBy'
118
+ 'poweredBy',
119
+ // SPEC V1.10 §2. Present in ck-locales.js for ro only; every other external
120
+ // locale falls back to DICT.en through buildStrings(), which is what §2
121
+ // asks for («остальные языки — en»).
122
+ 'phText', 'phAllow', 'phSettings', 'phLabel'
107
123
  ];
108
124
 
109
125
  // builtin(en,ru) <- window.__ckLocales, read at render time so the locales
@@ -161,6 +177,47 @@
161
177
  return out;
162
178
  }
163
179
 
180
+ /* ------------------------------------------- blocked-embed placeholder (§2) */
181
+
182
+ /* The one piece of the placeholder that is worth testing without a DOM: which
183
+ sentence a visitor reads. PURE — takes a host (or vendor label), a category
184
+ name and a language, returns the finished string.
185
+
186
+ `host` is used verbatim: the core has no vendor-label table (only the SaaS
187
+ scanner's VENDOR_DB does, server-side), so §2's «ярлык хоста; без ярлыка —
188
+ содержимое с <host>» collapses to the host here. When a label lookup is
189
+ added to the core later, this signature already accepts it — pass the label
190
+ instead of the host and nothing else changes.
191
+
192
+ The category name is resolved through the SAME buildStrings() the panel
193
+ uses, so «Маркетинг» in the placeholder is «Маркетинг» on the switch. An
194
+ unknown language falls back to en, an unknown category to marketing — the
195
+ category a strict-mode interception is filed under. */
196
+ function placeholderText(host, category, lang) {
197
+ var table = localeTable();
198
+ var T2 = buildStrings(resolveLang(lang, table), table);
199
+ var cat = (category && T2.cat[category]) ? category : 'marketing';
200
+ var name = String(host || '').trim();
201
+ return T2.phText
202
+ .replace('{host}', name || T2.phLabel)
203
+ .replace('{cat}', T2.cat[cat].title);
204
+ }
205
+
206
+ /* Hostname of a blocked frame's real address, for the sentence above. The
207
+ data-src is whatever the page asked for, which may be protocol-relative or
208
+ relative, so it is resolved against the page like the core does it. */
209
+ function hostOf(src) {
210
+ var s = String(src || '');
211
+ try {
212
+ var base = (typeof location !== 'undefined' && location.href) || 'http://localhost/';
213
+ var h = new URL(s, base).hostname || '';
214
+ return h.toLowerCase().replace(/^www\./, '');
215
+ } catch (e) {
216
+ var m = /^(?:[a-z]+:)?\/\/([^/?#]+)/i.exec(s);
217
+ return m ? m[1].toLowerCase().replace(/:\d+$/, '').replace(/^www\./, '') : '';
218
+ }
219
+ }
220
+
164
221
  /* --------------------------------------------------------------- styles */
165
222
 
166
223
  var CSS = [
@@ -1288,12 +1345,30 @@
1288
1345
  var url = str(texts.policyUrl);
1289
1346
  if (url && !/^https?:\/\//i.test(url)) url = null;
1290
1347
 
1348
+ /* SPEC V1.10 §1 — the third destination: our own cookie declaration page.
1349
+ `declarationUrl` is server-owned (the SaaS config injects it, like
1350
+ `branding`); the client only reads it and never invents one. It is
1351
+ validated exactly like policyUrl — http(s) only, because a link the
1352
+ visitor is invited to click must not be able to carry javascript: — and
1353
+ it deliberately does NOT influence the default action: the URL-sensitive
1354
+ default above stays policyUrl-driven, so a site that gains a declaration
1355
+ address does not silently lose its «Подробнее» → политика link. */
1356
+ var decl = str(texts.declarationUrl);
1357
+ if (decl && !/^https?:\/\//i.test(decl)) decl = null;
1358
+
1291
1359
  var action = texts.detailsAction;
1292
- if (action !== 'policy' && action !== 'settings' && action !== 'hide') {
1360
+ if (action !== 'policy' && action !== 'settings' && action !== 'hide' && action !== 'declaration') {
1293
1361
  action = url ? 'policy' : 'settings';
1294
1362
  }
1295
1363
  if (action === 'policy' && !url) action = 'settings';
1296
- return { kind: action, href: action === 'policy' ? url : null };
1364
+ // Asked for the declaration with no address to send anyone to: fall back to
1365
+ // opening the settings rather than rendering a dead link.
1366
+ if (action === 'declaration' && !decl) action = 'settings';
1367
+
1368
+ var href = null;
1369
+ if (action === 'policy') href = url;
1370
+ else if (action === 'declaration') href = decl;
1371
+ return { kind: action, href: href };
1297
1372
  }
1298
1373
 
1299
1374
  // Unknown type -> bar/bottom. Known type with an unrecognized position ->
@@ -1335,11 +1410,15 @@
1335
1410
  p.appendChild(document.createTextNode(T.bannerText));
1336
1411
  } else {
1337
1412
  p.appendChild(document.createTextNode(T.bannerText + ' '));
1338
- if (det.kind === 'policy') {
1413
+ // 'policy' and 'declaration' are the same DOM shape — an outbound link in
1414
+ // a new tab — and differ only in where they point (resolveDetails picked
1415
+ // the address). Both must be listed here: a missing branch would silently
1416
+ // render 'declaration' as the settings BUTTON instead of the link.
1417
+ if (det.kind === 'policy' || det.kind === 'declaration') {
1339
1418
  var link = el('a', 'ck-banner__more', T.more);
1340
1419
  link.href = det.href;
1341
1420
  link.target = '_blank';
1342
- link.rel = 'noopener'; // never hand the policy page window.opener
1421
+ link.rel = 'noopener'; // never hand the linked page window.opener
1343
1422
  p.appendChild(link);
1344
1423
  } else {
1345
1424
  // A control that changes what is on screen is a button, not a link:
@@ -1607,6 +1686,290 @@
1607
1686
  if (nodes.scrim) nodes.scrim.classList.toggle('ck-hidden', decided || !nodes.bannerModal);
1608
1687
  if (nodes.fab) nodes.fab.classList.toggle('ck-hidden', !decided);
1609
1688
  if (!panelOpen) syncSwitches(s);
1689
+
1690
+ /* §2 — a consent change is exactly when a card must go: the core's
1691
+ applyConsentToDom() has already put the frame's src back by the time
1692
+ ck:change reaches us (commit() revives before it dispatches), so the
1693
+ sweep sees a frame with a src and retires its placeholder. It runs on
1694
+ every sync, which also catches a category the visitor turned back OFF —
1695
+ though a frame that already loaded cannot be un-loaded, so that direction
1696
+ only matters for frames still held back. */
1697
+ try { sweepPlaceholders(); } catch (e) { /* noop */ }
1698
+ }
1699
+
1700
+ /* ------------------------------------------- blocked-embed placeholders (§2) */
1701
+
1702
+ /* SPEC V1.10 §2. When the core holds an <iframe> back — a known tracker, or
1703
+ any third-party frame in strict mode — it leaves it in one shape:
1704
+ `data-ck` + `data-src` and NO src (markBlockedIframe in ck-core.js). This
1705
+ module draws a block of the same size in its place, offering the visitor
1706
+ the one decision that would make the embed appear.
1707
+
1708
+ WHY A SIBLING, NOT A WRAPPER: sites style embeds through the parent
1709
+ (`.video-wrap > iframe`, grid children, aspect-ratio boxes). Wrapping the
1710
+ frame inserts a node into that relationship and breaks the layout it was
1711
+ meant to preserve. A sibling inserted before the frame inherits the same
1712
+ parent context, and removing it later restores the DOM exactly.
1713
+
1714
+ WHO RESTORES THE FRAME: not this file. `Element.prototype.setAttribute` is
1715
+ patched by the core, and only applyConsentToDom() may write a blocked
1716
+ frame's src — under `bypass`, through the native setter it captured before
1717
+ patching. So «Разрешить и показать» grants the category through the normal
1718
+ consent path and the CORE brings the frame back; the next sweep sees a
1719
+ frame that has a src again and drops the placeholder. One code path for
1720
+ the button, the panel's switches and «Принять всё» alike. */
1721
+
1722
+ var PH_ATTR = 'data-ck-ph'; // marks a frame this file hid
1723
+ var PH_DISPLAY = 'data-ck-ph-display'; // its previous inline display value
1724
+ var placeholders = []; // [{ frame, node }]
1725
+
1726
+ function placeholdersEnabled(cfg) {
1727
+ // No default in the core's DEFAULT_CONFIG, exactly like detailsAction:
1728
+ // absent means on, and only an explicit false opts out.
1729
+ try {
1730
+ var b = cfg && cfg.blocking;
1731
+ return !(b && b.placeholders === false);
1732
+ } catch (e) { return true; }
1733
+ }
1734
+
1735
+ /* §2: «Не трогать фреймы display:none, 1×1, и те, что не в <body>.»
1736
+ Order matters — a frame this file has already hidden reads as display:none,
1737
+ which would make it skip its own placeholder and leave it stranded forever.
1738
+ Frames we marked are therefore exempted before the display test runs. */
1739
+ function frameEligible(frame) {
1740
+ try {
1741
+ if (!frame || !document.body) return false;
1742
+ if (!document.body.contains(frame)) return false;
1743
+ if (frame.getAttribute(PH_ATTR)) return true; // ours: already measured
1744
+ var cs = null;
1745
+ try { cs = window.getComputedStyle(frame); } catch (e) { cs = null; }
1746
+ if (cs && (cs.display === 'none' || cs.visibility === 'hidden')) return false;
1747
+ var r = null;
1748
+ try { r = frame.getBoundingClientRect(); } catch (e) { r = null; }
1749
+ // A 1×1 (or 0×0) frame is a tracking pixel dressed as an embed: there is
1750
+ // nothing for a visitor to watch and a card in its place would be noise.
1751
+ if (r && r.width <= 1 && r.height <= 1) {
1752
+ // Zero-sized because it is not laid out YET (a lazy tab, a collapsed
1753
+ // section) is indistinguishable here from a real pixel except via the
1754
+ // attributes, which a pixel sets to 1 and a video does not.
1755
+ var aw = parseInt(frame.getAttribute('width') || '0', 10);
1756
+ var ah = parseInt(frame.getAttribute('height') || '0', 10);
1757
+ if (!(aw > 1 || ah > 1)) return false;
1758
+ }
1759
+ return true;
1760
+ } catch (e) { return false; }
1761
+ }
1762
+
1763
+ /* Size the card to the hole the frame left. Attributes first (an embed is
1764
+ nearly always `width="560" height="315"`), computed size second. Read
1765
+ BEFORE the frame is hidden — getComputedStyle on a display:none element
1766
+ reports nothing worth copying. */
1767
+ function frameSize(frame) {
1768
+ var w = '', h = '';
1769
+ try {
1770
+ var aw = frame.getAttribute('width');
1771
+ var ah = frame.getAttribute('height');
1772
+ if (aw && /^\d+$/.test(String(aw).trim())) w = String(aw).trim() + 'px';
1773
+ if (ah && /^\d+$/.test(String(ah).trim())) h = String(ah).trim() + 'px';
1774
+ if (!w || !h) {
1775
+ var cs = window.getComputedStyle(frame);
1776
+ if (!w && cs && cs.width && cs.width !== 'auto' && cs.width !== '0px') w = cs.width;
1777
+ if (!h && cs && cs.height && cs.height !== 'auto' && cs.height !== '0px') h = cs.height;
1778
+ }
1779
+ } catch (e) { /* fall through to the defaults in PH_CSS */ }
1780
+ return { width: w, height: h };
1781
+ }
1782
+
1783
+ var PLAY_ICON =
1784
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" ' +
1785
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' +
1786
+ '<rect x="2.5" y="4.5" width="19" height="15" rx="2.5"/>' +
1787
+ '<path d="M10.5 9.2v5.6l4.6-2.8-4.6-2.8Z"/></svg>';
1788
+
1789
+ // Own sheet: a placeholder is its own shadow root, so it cannot borrow the
1790
+ // banner's. Tokens come from buildThemeCss() (theme.accent, radius, font),
1791
+ // which is why a placeholder repaints with the banner.
1792
+ var PH_CSS = [
1793
+ ':host{all:initial;display:block;max-width:100%}',
1794
+ '*,*::before,*::after{box-sizing:border-box}',
1795
+ '.ck-ph{width:100%;height:100%;min-height:120px;max-width:100%;',
1796
+ 'display:flex;flex-direction:column;align-items:center;justify-content:center;',
1797
+ 'gap:10px;padding:20px;text-align:center;',
1798
+ 'font-family:var(--ck-font);font-size:14px;line-height:1.5;color:var(--ck-ink);',
1799
+ 'background:var(--ck-soft);border:1px solid var(--ck-line);',
1800
+ 'border-radius:var(--ck-radius-card)}',
1801
+ '.ck-ph svg{width:32px;height:32px;display:block;color:var(--ck-muted);flex:none}',
1802
+ '.ck-ph p{margin:0;color:var(--ck-muted);max-width:44ch}',
1803
+ '.ck-ph__row{display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:center}',
1804
+ '.ck-ph__btn{font:inherit;font-weight:600;cursor:pointer;padding:9px 16px;',
1805
+ 'border-radius:var(--ck-radius-btn);border:1px solid var(--ck-accent);',
1806
+ 'background:var(--ck-accent);color:var(--ck-on-accent)}',
1807
+ '.ck-ph__link{font:inherit;background:none;border:0;padding:4px;cursor:pointer;',
1808
+ 'color:var(--ck-link);text-decoration:underline}',
1809
+ ':focus-visible{outline:2px solid var(--ck-accent);outline-offset:2px;border-radius:4px}'
1810
+ ].join('\n');
1811
+
1812
+ function categoryEnabled(cfg, cat) {
1813
+ try {
1814
+ if (cat === 'necessary') return false; // nothing to grant
1815
+ var c = cfg && cfg.categories && cfg.categories[cat];
1816
+ return !c || c.enabled !== false;
1817
+ } catch (e) { return true; }
1818
+ }
1819
+
1820
+ function buildPlaceholder(frame, cat, cfg) {
1821
+ var mountEl = document.createElement('div');
1822
+ mountEl.setAttribute('data-ck-placeholder', '1');
1823
+ var size = frameSize(frame);
1824
+ var st = mountEl.style;
1825
+ st.setProperty('max-width', '100%');
1826
+ if (size.width) st.setProperty('width', size.width);
1827
+ if (size.height) st.setProperty('min-height', size.height);
1828
+
1829
+ var sr = mountEl.attachShadow({ mode: 'open' });
1830
+ var sheet = document.createElement('style');
1831
+ /* buildThemeCss writes :host tokens — the same tokens, in this root, which
1832
+ is what makes a placeholder repaint with the banner.
1833
+
1834
+ The font gets applyTheme()'s treatment for the same reason it needs it
1835
+ there: `:host{all:initial}` resets the family, so a bare `inherit` in
1836
+ this root resolves to the UA default (Times), not to the page's type.
1837
+ The probed page font when it can be read, the system stack when it
1838
+ cannot — never a bare `inherit`. */
1839
+ var built = buildThemeCss(cfg);
1840
+ var phFont = '';
1841
+ if (built.font === 'inherit') {
1842
+ phFont = '\n:host{--ck-font:' + (resolvePageFont() || SYSTEM_FONT) + '}';
1843
+ }
1844
+ sheet.textContent = PH_CSS + '\n' + built.css + phFont;
1845
+ sr.appendChild(sheet);
1846
+
1847
+ var card = el('div', 'ck-ph');
1848
+ card.setAttribute('role', 'group');
1849
+ card.setAttribute('aria-label', T.phLabel);
1850
+
1851
+ var icon = document.createElement('div');
1852
+ icon.innerHTML = PLAY_ICON;
1853
+ card.appendChild(icon);
1854
+
1855
+ var label = hostOf(frame.getAttribute('data-src'));
1856
+ card.appendChild(el('p', null, placeholderText(label, cat, LANG)));
1857
+
1858
+ var row = el('div', 'ck-ph__row');
1859
+ /* Only offer the grant when it can actually take effect: the core's
1860
+ filterByConfig() drops a category the site disabled in config, so the
1861
+ button would consume the click and change nothing. The panel link stays
1862
+ either way — it is always an honest answer. */
1863
+ if (categoryEnabled(cfg, cat)) {
1864
+ var allow = el('button', 'ck-ph__btn', T.phAllow);
1865
+ allow.type = 'button';
1866
+ allow.addEventListener('click', function () { grantCategory(cat); });
1867
+ row.appendChild(allow);
1868
+ }
1869
+ var settings = el('button', 'ck-ph__link', T.phSettings);
1870
+ settings.type = 'button';
1871
+ settings.addEventListener('click', function () { openPanel(settings); });
1872
+ row.appendChild(settings);
1873
+ card.appendChild(row);
1874
+
1875
+ sr.appendChild(card);
1876
+ return mountEl;
1877
+ }
1878
+
1879
+ /* «Разрешить и показать» — one category, through the normal consent path.
1880
+
1881
+ MERGE, DO NOT REPLACE: ConsentKit.accept({...}) SETS the whole opt-in set
1882
+ from the object it is given, so passing `{marketing:true}` alone would
1883
+ silently switch OFF an analytics consent the visitor had already given.
1884
+ The current state is read first and only the one category is flipped.
1885
+
1886
+ accept(object) files the decision as method 'custom', which is what §2 asks
1887
+ the journal to record, and ck-saas.js logs it off the ck:consent/ck:change
1888
+ the core dispatches — so there is no logging code here. The core's
1889
+ applyConsentToDom() restores the frame; sweepPlaceholders() then removes
1890
+ this card, driven by the ck:change that same commit dispatches. */
1891
+ function grantCategory(cat) {
1892
+ var ck = api();
1893
+ if (!ck || typeof ck.accept !== 'function') return;
1894
+ var cur = safeState().categories || {};
1895
+ var next = {
1896
+ functional: cur.functional === true,
1897
+ analytics: cur.analytics === true,
1898
+ marketing: cur.marketing === true
1899
+ };
1900
+ if (cat === 'functional' || cat === 'analytics' || cat === 'marketing') next[cat] = true;
1901
+ try { ck.accept(next); } catch (e) { /* noop */ }
1902
+ syncFromState();
1903
+ }
1904
+
1905
+ function hideFrame(frame) {
1906
+ try {
1907
+ frame.setAttribute(PH_DISPLAY, frame.style.display || '');
1908
+ frame.setAttribute(PH_ATTR, '1');
1909
+ frame.style.display = 'none';
1910
+ } catch (e) { /* noop */ }
1911
+ }
1912
+
1913
+ function restoreFrame(frame) {
1914
+ try {
1915
+ var prev = frame.getAttribute(PH_DISPLAY);
1916
+ frame.style.display = prev || '';
1917
+ frame.removeAttribute(PH_DISPLAY);
1918
+ frame.removeAttribute(PH_ATTR);
1919
+ } catch (e) { /* noop */ }
1920
+ }
1921
+
1922
+ function dropPlaceholder(entry) {
1923
+ try {
1924
+ if (entry.node && entry.node.parentNode) entry.node.parentNode.removeChild(entry.node);
1925
+ } catch (e) { /* noop */ }
1926
+ restoreFrame(entry.frame);
1927
+ }
1928
+
1929
+ function clearPlaceholders() {
1930
+ for (var i = 0; i < placeholders.length; i++) dropPlaceholder(placeholders[i]);
1931
+ placeholders = [];
1932
+ }
1933
+
1934
+ /* Idempotent: called from mount() and from every consent change. Adds cards
1935
+ for frames still held back, removes them from frames the core revived. */
1936
+ function sweepPlaceholders(cfg) {
1937
+ if (typeof document === 'undefined' || !document.body) return;
1938
+ var c = cfg || safeConfig();
1939
+
1940
+ // Placeholders turned off after some were drawn: take them all down.
1941
+ if (!placeholdersEnabled(c)) { clearPlaceholders(); return; }
1942
+
1943
+ // 1. Retire cards whose frame came back (or left the document).
1944
+ var kept = [];
1945
+ for (var i = 0; i < placeholders.length; i++) {
1946
+ var e = placeholders[i];
1947
+ var revived = false;
1948
+ try {
1949
+ revived = !document.body.contains(e.frame) || !!e.frame.getAttribute('src');
1950
+ } catch (e2) { revived = true; }
1951
+ if (revived) dropPlaceholder(e); else kept.push(e);
1952
+ }
1953
+ placeholders = kept;
1954
+
1955
+ // 2. Draw cards for frames the core is holding back. The selector is the
1956
+ // core's own revival selector, which is why a frame the SITE allowed can
1957
+ // never match: an allowed frame keeps its src and is never marked.
1958
+ var list;
1959
+ try { list = document.querySelectorAll('iframe[data-ck][data-src]'); } catch (e3) { return; }
1960
+ for (var j = 0; j < list.length; j++) {
1961
+ var frame = list[j];
1962
+ try {
1963
+ if (frame.getAttribute('src')) continue; // already revived
1964
+ if (frame.getAttribute(PH_ATTR)) continue; // already carded
1965
+ if (!frameEligible(frame)) continue;
1966
+ var cat = frame.getAttribute('data-ck') || 'marketing';
1967
+ var node = buildPlaceholder(frame, cat, c);
1968
+ if (frame.parentNode) frame.parentNode.insertBefore(node, frame);
1969
+ hideFrame(frame);
1970
+ placeholders.push({ frame: frame, node: node });
1971
+ } catch (e4) { /* one bad frame must not stop the sweep */ }
1972
+ }
1610
1973
  }
1611
1974
 
1612
1975
  /* ----------------------------------------------------------------- mount */
@@ -1637,6 +2000,10 @@
1637
2000
  mounted = false;
1638
2001
  panelOpen = false;
1639
2002
  lastFocus = null;
2003
+ // The cards belong to the render about to be replaced: their listeners close
2004
+ // over the old T and the old shadow root. mount() sweeps again and draws
2005
+ // fresh ones in the new language and theme.
2006
+ clearPlaceholders();
1640
2007
  // The pending schedule belongs to the shadow root about to be rebuilt; the
1641
2008
  // fresh mount() starts its own ladder from a clean count.
1642
2009
  clearFontTimers();
@@ -1692,6 +2059,55 @@
1692
2059
  // Only now: scheduleFontProbes() re-applies the theme from a timer, and
1693
2060
  // applyTheme() is a no-op until there is a host and a themeStyle to write.
1694
2061
  scheduleFontProbes(cfg);
2062
+
2063
+ // SPEC V1.10 §2 — заглушки вместо задержанных встраиваний. After the shadow
2064
+ // root exists, because a placeholder's «Настроить cookie» calls openPanel().
2065
+ sweepPlaceholders(cfg);
2066
+
2067
+ /* SPEC V1.10 §1 — honour a settings request that arrived before there was
2068
+ anything to open: either ConsentKit.openSettings() called while this file
2069
+ was still loading (the core latches it), or a page opened directly on
2070
+ `#ck-settings`. Both end in the same panel. */
2071
+ consumePendingOpen();
2072
+ openFromHash();
2073
+ }
2074
+
2075
+ /* ---------------------------------------------------- settings deep link */
2076
+
2077
+ var SETTINGS_HASH = '#ck-settings';
2078
+
2079
+ // The core latches openSettings() calls made before ck-ui.js was parsed, so a
2080
+ // footer link clicked during a slow load still opens the panel once we exist.
2081
+ function consumePendingOpen() {
2082
+ var ck = api();
2083
+ if (!ck || !ck._pendingOpen) return;
2084
+ try { ck._pendingOpen = false; } catch (e) { /* noop */ }
2085
+ openPanel(null);
2086
+ }
2087
+
2088
+ /* `https://site/#ck-settings` — the address the cookie declaration page links
2089
+ «Изменить выбор cookie» to. The hash is removed again via replaceState so a
2090
+ reload, a shared link or a back-navigation does not re-open the panel, and
2091
+ so the address bar does not keep a control fragment in it.
2092
+
2093
+ replaceState is fed pathname+search rather than '' — an empty URL argument
2094
+ is a no-op in some engines, which would leave the hash in place and re-open
2095
+ the panel on the next hashchange. Everything is guarded: a sandboxed iframe
2096
+ throws on replaceState, and a panel that opened is worth more than a tidy
2097
+ address bar. */
2098
+ function openFromHash() {
2099
+ try {
2100
+ if (typeof location === 'undefined' || location.hash !== SETTINGS_HASH) return;
2101
+ } catch (e) { return; }
2102
+ clearSettingsHash();
2103
+ openPanel(null);
2104
+ }
2105
+
2106
+ function clearSettingsHash() {
2107
+ try {
2108
+ if (typeof history === 'undefined' || typeof history.replaceState !== 'function') return;
2109
+ history.replaceState(null, '', location.pathname + location.search);
2110
+ } catch (e) { /* noop */ }
1695
2111
  }
1696
2112
 
1697
2113
  /* ---------------------------------------------------------------- events */
@@ -1722,7 +2138,11 @@
1722
2138
  nextProbeDelay: nextProbeDelay,
1723
2139
  shouldReprobe: shouldReprobe,
1724
2140
  resolveDetails: resolveDetails,
1725
- buildThemeCss: buildThemeCss
2141
+ buildThemeCss: buildThemeCss,
2142
+ // SPEC V1.10 §2: which sentence a blocked embed shows. Pure, so the
2143
+ // wording is testable (and quotable by the cabinet) without a DOM.
2144
+ placeholderText: placeholderText,
2145
+ placeholdersEnabled: placeholdersEnabled
1726
2146
  };
1727
2147
  // The page-font probe reads the DOM, so it is not part of the pure block —
1728
2148
  // but the debug panel must be able to quote the family the banner painted
@@ -1768,9 +2188,29 @@
1768
2188
 
1769
2189
  document.addEventListener('ck:ui:open-preferences', function () {
1770
2190
  if (!mounted) mount(safeConfig());
2191
+ // mount() consumes the core's latch itself; clear it here too so a call
2192
+ // made while we were already mounted cannot leave a stale flag behind for
2193
+ // a later remount to act on.
2194
+ var ck = api();
2195
+ if (ck && ck._pendingOpen) { try { ck._pendingOpen = false; } catch (e) { /* noop */ } }
1771
2196
  openPanel(null);
1772
2197
  });
1773
2198
 
2199
+ /* SPEC V1.10 §1 — navigating to `#ck-settings` on a page that is already
2200
+ loaded (a footer link, or the declaration page opened in the same tab).
2201
+ mount() covers the other half: a page ENTERED on that hash.
2202
+
2203
+ Feature-checked rather than assumed: the SSR guard above only proves there
2204
+ is a `document`, and the branding suite evaluates this file against a stub
2205
+ window that has none of the event plumbing. A missing hashchange costs the
2206
+ deep link, not the banner. */
2207
+ if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
2208
+ window.addEventListener('hashchange', function () {
2209
+ if (!mounted) mount(safeConfig());
2210
+ openFromHash();
2211
+ });
2212
+ }
2213
+
1774
2214
  document.addEventListener('ck:ui:close', function () {
1775
2215
  closePanel();
1776
2216
  });