@ecomconsult/consentkit 0.5.10 → 0.5.12

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
@@ -1,6 +1,6 @@
1
1
  # ConsentKit
2
2
 
3
- ![status: prototype v0.3](https://img.shields.io/badge/status-prototype%20v0.3-orange)
3
+ ![status: prototype v0.5](https://img.shields.io/badge/status-prototype%20v0.5-orange)
4
4
  ![license: MIT](https://img.shields.io/badge/license-MIT-blue)
5
5
  ![dependencies: 0](https://img.shields.io/badge/dependencies-0-brightgreen)
6
6
  ![no build step](https://img.shields.io/badge/build-none-lightgrey)
@@ -24,17 +24,18 @@ Vanilla ES2020, zero dependencies, no build step.
24
24
  (e.g. a direct GA4 `gtag/js` tag) cannot be intercepted before the request
25
25
  leaves — mark those up manually; the SaaS install check points at the exact tag
26
26
  - **UI:** banner (`bar` / `box` / `modal`), preferences panel, floating re-open
27
- button, light/dark, 30+ locales
27
+ button, light/dark, 34 locales
28
28
  - **SSR-safe:** importing on the server never touches the DOM
29
29
  - **Equal-weight buttons, no pre-ticked boxes** — the consent invariants are
30
- fixed by design, see [CONTRIBUTING.md](CONTRIBUTING.md)
30
+ fixed by design, see [CONTRIBUTING.md](https://github.com/vermoh/ConsentKit/blob/main/CONTRIBUTING.md)
31
31
 
32
- > **Status: prototype (v0.3).** The core, the UI and the demo are verified in a
33
- > browser; several distribution paths are not yet tested against live systems.
34
- > See [Project status](#project-status) before shipping this to production.
32
+ > **Status: prototype (v0.5.12).** The core, the UI and the demo are verified in
33
+ > a browser and covered by an automated suite (`npm test`); several distribution
34
+ > paths are not yet tested against live systems. See
35
+ > [Project status](#project-status) before shipping this to production.
35
36
 
36
37
  **Не программист?** Пошаговая инструкция по-русски, с картинками и разбором по
37
- кликам: **[INSTALL.ru.md](INSTALL.ru.md)**.
38
+ кликам: **[INSTALL.ru.md](https://github.com/vermoh/ConsentKit/blob/main/INSTALL.ru.md)**.
38
39
 
39
40
  ## Install
40
41
 
@@ -42,10 +43,11 @@ Four ways to add ConsentKit to a site, from simplest to most integrated.
42
43
 
43
44
  | # | Method | Best for | Docs |
44
45
  |---|---|---|---|
46
+ | 0 | **Prebuilt block** — copy one file from `ready/` into `<head>`, nothing to install | Tilda and other site builders; no developer needed | [`ready/README.md`](https://github.com/vermoh/ConsentKit/blob/main/ready/README.md) |
45
47
  | 1 | **Script tags** — copy `src/` to your server, three `<script>` tags in `<head>` | Any site you control | [Quickstart below](#quickstart--script-tags) |
46
48
  | 2 | **npm** — `npm install @ecomconsult/consentkit` | Bundled apps, React | [Quickstart below](#quickstart--npm) |
47
- | 3 | **WordPress plugin** — copy the plugin folder to `wp-content/plugins/`; rewrites static tracker tags server-side | WordPress / WooCommerce | [`plugins/wordpress/consentkit/`](plugins/wordpress/consentkit/) |
48
- | 4 | **Google Tag Manager** — import the container, trigger tags on consent events | Sites already running GTM | [`integrations/gtm/README.md`](integrations/gtm/README.md) |
49
+ | 3 | **WordPress plugin** — copy the plugin folder to `wp-content/plugins/`; rewrites static tracker tags server-side | WordPress / WooCommerce | [`plugins/wordpress/consentkit/`](https://github.com/vermoh/ConsentKit/tree/main/plugins/wordpress/consentkit) |
50
+ | 4 | **Google Tag Manager** — import the container, trigger tags on consent events | Sites already running GTM | [`integrations/gtm/README.md`](https://github.com/vermoh/ConsentKit/blob/main/integrations/gtm/README.md) |
49
51
 
50
52
  ```sh
51
53
  npm install @ecomconsult/consentkit
@@ -201,6 +203,7 @@ Pass any subset to `init()`. Nested objects merge with the defaults.
201
203
  | `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 |
202
204
  | `cookieTable` | `CkCookieTableEntry[]` | `[]` | Declared cookies, listed per category in the panel |
203
205
  | `services` | `CkService[]` | `[]` | v0.5.8. Third-party services the site declares. Each gets its own toggle inside its category group in the panel, and can be refused individually — see [Services](#services). At most 50 |
206
+ | `branding` | `object` | absent | v0.3.5. The attribution line (and optional logo) at the foot of the banner, rendered by `src/ck-ui-branding.js`. Absent from the defaults: omit the key and nothing renders — see [Branding](#branding) |
204
207
 
205
208
  `cookieTable` entries:
206
209
 
@@ -230,6 +233,26 @@ services: [{
230
233
  }]
231
234
  ```
232
235
 
236
+ **How the list is laid out (v0.5.12).** A group's «N services · M cookies» line
237
+ is a disclosure **button**, and it is **collapsed by default** — eight services
238
+ no longer push the switches the visitor came for off the screen. Opening it
239
+ reveals the group's services and, at the end, the group's own «Which cookies
240
+ (N)» table. Each service is **one line**: name, vendor, and its switch on the
241
+ right. Everything else — the purpose, the privacy link, «Cookies it sets (N)» —
242
+ lives behind that row's own small «Details» disclosure. Both controls are real
243
+ focusable controls with a visible focus ring, and both are translated in ru, ro
244
+ and en (every other language falls back to en, as elsewhere).
245
+
246
+ A group with **no services** is unchanged from 0.5.7: no counter, no disclosure,
247
+ and its cookie table sits at the top level where it always did.
248
+
249
+ **A service in the `necessary` group gets no switch** (v0.5.12). That group
250
+ cannot be refused, so a control that could only ever sit at «off» would be
251
+ telling the visitor something untrue — the «always on» badge is rendered in its
252
+ place. `allowedService(id)` answers `true` for such a service unconditionally,
253
+ and a refusal for one is stripped from the denial map on every read and write,
254
+ including a record written by hand or by an older client.
255
+
233
256
  What the toggle does:
234
257
 
235
258
  - **Group off** — every service of that group is off and blocked, as before.
@@ -245,7 +268,8 @@ the block map under the row's own category, so a service host ConsentKit has
245
268
  never heard of is still held back.
246
269
 
247
270
  Refusals are stored in `ck_consent` as `services: { '<id>': false }` — denials
248
- only. An id absent from the map is allowed, subject to its category.
271
+ only. An id absent from the map is allowed, subject to its category. A
272
+ `necessary` service is never in the map.
249
273
 
250
274
  ### Button appearance
251
275
 
@@ -281,8 +305,8 @@ border rule apply to *derived* values — the automatic text on a filled button,
281
305
  the border and text an `outline` button takes from `theme.accent`, the link
282
306
  colour read off the accent. A `fg`, `border` or `onAccent` you wrote yourself
283
307
  is never repainted, however low it measures: the debug panel reports the ratio
284
- and warns («контраст 4.32 ниже рекомендуемых 4.5»), and the choice stays
285
- yours. Concretely:
308
+ and warns that it is below the recommended floor (for example, "contrast 4.32
309
+ below the recommended 4.5"), and the choice stays yours. Concretely:
286
310
 
287
311
  - a `fg` you set is painted as set and only measured against the fill behind
288
312
  it; the *derived* text on a filled button still becomes white or `#161616`,
@@ -299,6 +323,13 @@ The card is `#ffffff` in light mode and `#1c1c1e` in dark. A colour the
299
323
  arithmetic cannot read — a CSS colour name, an `rgb()` string — is left exactly
300
324
  as you wrote it rather than being silently replaced.
301
325
 
326
+ **The preferences panel follows the banner, with no settings of its own**
327
+ (v0.5.11): the panel's «save choice» button is styled as `accept`, the panel's
328
+ own accept and reject as `settings`, and the floating button takes the
329
+ `accept` button's fill
330
+ (its border colour when `accept` is `outline`), while the category and service
331
+ switches keep `theme.accent`.
332
+
302
333
  The same arithmetic is exposed as pure functions on `ConsentKit._contrast`
303
334
  (`relativeLuminance`, `contrastRatio`, `ensureContrast`, `stepToContrast`,
304
335
  `resolveButtonStyles`, `resolveRadius`, `resolveFont`, `resolveDetails`,
@@ -344,16 +375,16 @@ safe to call before the banner has mounted: a call that arrives while the UI
344
375
  file is still loading is remembered and honoured on mount, so a link clicked
345
376
  during a slow page load still works.
346
377
 
347
- The same panel has an address. Ссылка «Изменить выбор cookie»
348
- `https://site/#ck-settings`: любая ссылка на страницу сайта с этим хвостом
349
- открывает окно настроек и при загрузке страницы, и при переходе по ссылке на
350
- уже открытой странице. Хвост убирается из адреса через `history.replaceState`,
351
- поэтому перезагрузка или «назад» не открывают окно повторно. Это тот адрес, на
352
- который ведёт кнопка «Изменить выбор cookie» на странице декларации cookie, и
353
- его же удобно поставить в подвал сайта:
378
+ The same panel also has an address. Any link to a page of the site ending in
379
+ `#ck-settings` opens the preferences panel both when the page loads with that
380
+ fragment and when the link is followed on an already-open page. The fragment is
381
+ then removed from the address with `history.replaceState`, so a reload or a
382
+ «back» does not reopen the panel. This is the address the «change your cookie
383
+ choice» button on a cookie declaration page points at, and the one to put in a
384
+ site footer:
354
385
 
355
386
  ```html
356
- <a href="#ck-settings">Изменить выбор cookie</a>
387
+ <a href="#ck-settings">Change your cookie choice</a>
357
388
  ```
358
389
 
359
390
  ### Placeholders for blocked embeds
@@ -362,7 +393,7 @@ When the engine holds back an `<iframe>` before consent — a known tracker, or
362
393
  any third-party frame in strict mode — the visitor would otherwise see an empty
363
394
  hole where a video or a map should be. Since 0.5.7 ConsentKit draws a card in
364
395
  its place: the name of the service, the category the embed is waiting for, a
365
- primary button «Разрешить и показать» that grants **that one category** and
396
+ primary button ("Allow and show") that grants **that one category** and
366
397
  loads the embed, and a link to the full settings panel.
367
398
 
368
399
  The card is sized from the frame's own `width`/`height` (or its computed size),
@@ -402,8 +433,9 @@ decide. Membership is a claim that a host delivers the site's own assets, not
402
433
  that it is harmless in general — anything that *measures* keeps a real consent
403
434
  category instead, which is why `static.cloudflareinsights.com` (Cloudflare Web
404
435
  Analytics) is classified as `analytics` and blocked before consent even though
405
- the rest of Cloudflare's CDN is infrastructure. Both lists are matched by suffix
406
- and returned as copies, so reading them cannot widen what strict mode allows.
436
+ the rest of Cloudflare's CDN is infrastructure. The list holds **37 entries**.
437
+ Both lists are matched by suffix and returned as copies, so reading them cannot
438
+ widen what strict mode allows.
407
439
 
408
440
  ## API
409
441
 
@@ -442,6 +474,44 @@ All methods are safe to call at any time and never throw.
442
474
  Already-loaded scripts are not unloaded by `withdraw()` — cookies are cleared
443
475
  and the next page load is clean.
444
476
 
477
+ ### Introspection
478
+
479
+ Members prefixed with `_` are **not** private-by-convention placeholders: they
480
+ are a deliberate read-only surface for tooling — the debug panel, the hosted
481
+ cabinet's theme editor, and tests — and they are documented because those
482
+ consumers depend on them. They are stable within a minor version, and every one
483
+ of them returns a copy, so reading can never widen what the engine allows.
484
+
485
+ | Member | Returns | Description |
486
+ |---|---|---|
487
+ | `_blocked()` | `array` | What the engine is currently holding back, plus a sweep of blocked markup. Drives the debug panel's list |
488
+ | `_categoryForUrl(url)` | `string \| null` | The category the database gives a URL — the same lookup the engine uses |
489
+ | `_categories` | `string[]` | The four category names, as a copy |
490
+ | `_services()` | `array` | The normalised service rows from the config |
491
+ | `_serviceForUrl(url)` | `object \| null` | Which declared service a URL belongs to |
492
+ | `_deniedServices()` | `string[]` | Ids the visitor refused individually |
493
+ | `_extendHostDb(map)` | `number` | Merge extra `host: category` pairs; returns how many were accepted — see [Extending the tracker database](#extending-the-tracker-database) |
494
+ | `_infra()` | `string[]` | The 37 infrastructure hosts, as a copy |
495
+ | `_isInfra(url)` | `boolean` | Is this URL or hostname infrastructure? |
496
+ | `_baseAllow` | `object` | The built-in strict-mode allowlist (hosts plus path-scoped entries), as a copy |
497
+
498
+ `ConsentKit._contrast`, published by `src/ck-ui.js`, exposes the theme
499
+ arithmetic as pure functions so a theme editor can show exactly the numbers the
500
+ banner paints rather than reimplementing them. Nothing in it touches the DOM, so
501
+ it is safe to call in Node:
502
+
503
+ | Group | Functions |
504
+ |---|---|
505
+ | Colour maths | `relativeLuminance`, `contrastRatio`, `ensureContrast`, `stepToContrast` |
506
+ | Resolution | `resolveButtonStyles`, `resolveRadius`, `resolveFont`, `pickPageFont`, `resolveDetails`, `buildThemeCss` |
507
+ | Font probing | `nextProbeDelay`, `shouldReprobe` |
508
+ | Placeholders | `placeholderText`, `placeholdersEnabled` |
509
+ | Services panel | `cookieRowsForService`, `looseCookies`, `servicePurpose`, `groupCountLabel`, `serviceSignature`, `signature` |
510
+ | Wording | `plural`, `pluralIndex`, `buildStrings`, `localeTable`, `resolveLang` |
511
+
512
+ `ConsentKit._resolvePageFont()` reports the font family the banner resolved from
513
+ the page.
514
+
445
515
  ## Events
446
516
 
447
517
  All are `CustomEvent` on `document`, with the payload in `detail`.
@@ -464,6 +534,20 @@ document.addEventListener('ck:change', (e) => {
464
534
  The core never touches the UI directly; it only dispatches these events, and the
465
535
  UI layer only calls the public API.
466
536
 
537
+ ### dataLayer events (GTM)
538
+
539
+ Separately from the DOM events above, `integrations.gtmDataLayer` (on by
540
+ default) pushes to `window.dataLayer`, which is what GTM triggers listen to:
541
+
542
+ | Push | When |
543
+ |---|---|
544
+ | `ck_consent_update` with `ck_consent: { necessary, functional, analytics, marketing }` and `ck_method` | Every decision, including `withdraw()` |
545
+ | `ck_consent_functional` / `ck_consent_analytics` / `ck_consent_marketing` | Once per granted category, on the decision and again on a return visit when stored consent is restored. Each fires at most once per page |
546
+
547
+ The per-category events exist so a GTM tag can trigger on exactly the category
548
+ it needs without reading the payload. See
549
+ [`integrations/gtm/README.md`](https://github.com/vermoh/ConsentKit/blob/main/integrations/gtm/README.md).
550
+
467
551
  ## Blocking trackers
468
552
 
469
553
  ### Manual markup
@@ -511,9 +595,23 @@ document.head.appendChild(s);
511
595
  ```
512
596
 
513
597
  Blocked elements are marked `data-ck-blocked` and their URL is remembered, so
514
- granting consent later loads them without a reload. Recognised hosts include
515
- Google Analytics, Google Tag Manager, Facebook, Yandex Metrica, Hotjar, TikTok
516
- and DoubleClick.
598
+ granting consent later loads them without a reload.
599
+
600
+ The database ships **116 hosts** and **11 path rules**, matched by suffix (a
601
+ bare registrable domain also covers its subdomains) and by substring
602
+ respectively:
603
+
604
+ | Table | Entries | By category |
605
+ |---|---|---|
606
+ | `HOST_DB` | 116 | 40 `functional`, 32 `marketing`, 31 `analytics`, 13 `necessary` |
607
+ | `PATH_DB` | 11 | 4 `marketing`, 3 `functional`, 2 `analytics`, 2 `necessary` |
608
+ | `INFRA_DB` | 37 | not a category — see [Infrastructure](#infrastructure) |
609
+
610
+ Recognised hosts include Google Analytics, Facebook, Yandex Metrica, Hotjar,
611
+ TikTok and DoubleClick. The GTM **container** is deliberately not blocked (the
612
+ tags inside it obey Consent Mode); `/gtag/js` is blocked by path instead. The
613
+ same tables are exported to the WordPress plugin, so server and browser
614
+ classify a host identically.
517
615
 
518
616
  Because the patches install at parse time, `ck-core.js` must load before any
519
617
  tracker — put it first in `<head>` and do not add `defer`.
@@ -601,8 +699,8 @@ Two limits are worth stating plainly:
601
699
 
602
700
  Runtime injection is covered by the patches above. A tracker tag written
603
701
  **directly into the HTML** is not: the parser starts that request before the
604
- first line of `ck-core.js` runs. The gap was measured (debt Д9: request at
605
- 14 ms, our script at 18 ms) and it is negative — no client-side technique
702
+ first line of `ck-core.js` runs. The gap was measured (debt D9 in SPEC.md:
703
+ request at 14 ms, our script at 18 ms) and it is negative — no client-side technique
606
704
  closes it. Such tags need either manual markup, or a server that rewrites them
607
705
  before the page is sent.
608
706
 
@@ -643,10 +741,38 @@ Outside WordPress the same idea applies to any server-side template: emit the
643
741
 
644
742
  ## Google Consent Mode v2
645
743
 
646
- With `integrations.gcm` (default), the core pushes `consent: default` with every
647
- signal `denied` at parse time, then `consent: update` after each choice:
648
- `analytics` `analytics_storage`; `marketing` `ad_storage`, `ad_user_data`,
649
- `ad_personalization`.
744
+ With `integrations.gcm` (the default), the core pushes `consent: default` at
745
+ parse time before any tag can load with every signal `denied` and
746
+ `wait_for_update: 500`, then `consent: update` after each choice. Seven signals
747
+ are set, always as one block:
748
+
749
+ | Signal | Follows |
750
+ |---|---|
751
+ | `analytics_storage` | `analytics` |
752
+ | `ad_storage`, `ad_user_data`, `ad_personalization` | `marketing` |
753
+ | `functionality_storage`, `personalization_storage` | `functional` |
754
+ | `security_storage` | always `granted` |
755
+
756
+ `integrations.gtmDataLayer` (also on by default) is an independent gate: it
757
+ pushes a `ck_consent_update` event carrying `ck_consent` (the four categories)
758
+ and `ck_method`, so GTM triggers work even with `gcm: false`.
759
+
760
+ **What "denied" actually means.** Consent Mode is Google's own mechanism, not a
761
+ block: a Google tag that runs under denied signals sets **no cookies and no
762
+ identifiers**, but it still sends *cookieless pings* to Google, and those pings
763
+ carry the **page URL, the referrer and the user agent**, from an IP address
764
+ Google necessarily sees. That is enough for Google to see the
765
+ visit, and in the EU an IP address is personal data. Consent Mode alone is
766
+ therefore not the same as not being measured.
767
+
768
+ ConsentKit's blocking engine is the part that makes the difference: a tag it
769
+ holds back never runs at all, so it sends nothing — no ping, no URL, no IP. The
770
+ two work together, and Consent Mode is the fallback for the case the engine
771
+ cannot cover (a tag inside a GTM container, or a static `<script src>` the
772
+ parser requested before ConsentKit loaded — see
773
+ [Static tags](#static-tags-what-the-browser-cannot-catch)). If you need "nothing
774
+ reaches Google before consent", rely on the blocking engine and mark such tags
775
+ up; do not rely on Consent Mode by itself.
650
776
 
651
777
  ## Storage
652
778
 
@@ -690,36 +816,31 @@ hosting, the cabinet and the scanner — not the line itself.
690
816
  For site builders that will not let you upload files, `ready/` holds
691
817
  ready-to-paste `<script>` blocks — copy one wholesale into `<head>`. Zero
692
818
  external requests. Rebuild them with `tools/build-inline.mjs` (see
693
- [`tools/README.md`](tools/README.md)); each block's header records the exact
819
+ [`tools/README.md`](https://github.com/vermoh/ConsentKit/blob/main/tools/README.md)); each block's header records the exact
694
820
  command that produced it.
695
821
 
696
- ConsentKit 0.5.0, rebuilt 2026-09-05, uncompressed — gzip on the server cuts
697
- this roughly three- to fourfold. Every block includes the branding extension
698
- and the attribution line; `--no-branding` drops both the code and the config
699
- and takes **~24 KB** back off:
822
+ ConsentKit 0.5.12, rebuilt 2026-09-07, uncompressed — gzip on the server cuts
823
+ this roughly threefold. Every block includes the branding extension and the
824
+ attribution line; `--no-branding` drops both the code and the config and takes
825
+ **~26 KB** back off:
700
826
 
701
827
  | Block | Languages | Bytes | gzip | `--no-branding` |
702
828
  |---|---|---|---|---|
703
- | `ready/en-bar.txt` | en | 160,388 | 48,950 | 136,326 |
704
- | `ready/ru-bar.txt` | ru, ro, en | 162,009 | 49,711 | 137,927 |
705
- | `ready/ru-box.txt` | ru, ro, en | 162,024 | 49,718 | 137,942 |
706
- | `ready/ru-box-right.txt` | ru, ro, en | 162,033 | 49,718 | 137,945 |
707
- | `ready/ru-modal.txt` | ru, ro, en | 162,017 | 49,713 | 137,933 |
708
- | `ready/eu-bar.txt` | 34 languages | 210,663 | 68,094 | 186,601 |
709
-
710
- 0.5.0 adds roughly 18.5 KB over 0.4.1 (about 6.1 KB gzipped): the contrast
711
- engine, the per-button token resolution, the new stylesheet rules and the
712
- comments explaining the rules the arithmetic implements. 0.4.1 had added
713
- roughly 6.3 KB over 0.4.0 (the infrastructure list), and 0.4.0 roughly 16.1 KB
714
- over 0.3.6: iframe interception, strict mode (same-site detection, the
715
- allowlists, the public-suffix table) and the extensible tracker database.
716
-
717
- Size is driven almost entirely by the bundled languages: `en` and `ru` are
718
- built into the UI and cost nothing extra, while layout, position, theme and
719
- accent change only a few bytes of config.
829
+ | `ready/en-bar.txt` | en | 264,816 | 82,798 | 238,438 |
830
+ | `ready/ru-bar.txt` | ru, ro, en | 266,806 | 83,673 | 240,242 |
831
+ | `ready/ru-box.txt` | ru, ro, en | 266,821 | 83,682 | 240,257 |
832
+ | `ready/ru-box-right.txt` | ru, ro, en | 266,830 | 83,687 | 240,266 |
833
+ | `ready/ru-modal.txt` | ru, ro, en | 266,814 | 83,679 | 240,250 |
834
+ | `ready/eu-bar.txt` | 34 languages | 315,460 | 101,998 | 288,916 |
835
+
836
+ The blocks are dominated by the core and the UI (roughly 97 KB and 129 KB of
837
+ source respectively, comments included the builder concatenates the sources
838
+ as they are and does not minify). Bundled languages account for the rest:
839
+ `en` and `ru` are built into the UI and cost nothing extra, while layout,
840
+ position, theme and accent change only a few bytes of config.
720
841
 
721
842
  The debug panel is **not** in these numbers. Blocks carry a ~5.1 KB loader
722
- (`src/ck-debug-loader.js`) which fetches the 33 KB panel only when someone opens
843
+ (`src/ck-debug-loader.js`) which fetches the ~54 KB panel only when someone opens
723
844
  the page with `?ck_debug=1` — see [Debug mode](#debug-mode). An ordinary visitor
724
845
  downloads the loader and nothing more.
725
846
 
@@ -762,7 +883,7 @@ a support ticket.
762
883
 
763
884
  ### How the panel gets onto the page
764
885
 
765
- The panel is ~33 KB, and on any given page exactly one person will ever open
886
+ The panel is ~54 KB, and on any given page exactly one person will ever open
766
887
  it. So `ready/*.txt` and the WordPress plugin ship **`src/ck-debug-loader.js`**
767
888
  (~5.1 KB) instead, and the loader fetches the panel on demand. With no flag set
768
889
  the loader creates no DOM, installs no observers and makes **no network
@@ -871,6 +992,24 @@ When the configuration contains a `log` endpoint, each decision is POSTed with
871
992
  via `sendBeacon` on `pagehide`. Withdrawals are sent with `method: "withdraw"`.
872
993
  No other network requests are made.
873
994
 
995
+ The payload is a **closed schema** — anything outside this list is rejected by
996
+ the server as a 400:
997
+
998
+ | Field | Always? | Value |
999
+ |---|---|---|
1000
+ | `siteId`, `key` | yes | The site id and the log key from the config |
1001
+ | `cfg` | yes | Version of the config the decision was made under |
1002
+ | `id`, `ts` | yes | uuid and ISO timestamp of the record. A withdrawal gets a fresh pair |
1003
+ | `categories` | yes | Exactly three booleans: `functional`, `analytics`, `marketing`. `necessary` is not part of the schema |
1004
+ | `method` | yes | `accept_all` \| `reject_all` \| `custom` \| `withdraw` |
1005
+ | `lang`, `layout` | when resolved | The language and layout the visitor actually saw |
1006
+ | `services` | only on `custom` | v0.5.8. The ids the visitor refused, sent only when the list is non-empty. Capped at 50 ids of at most 64 characters |
1007
+
1008
+ The client puts no page URL, referrer or user agent in the body. The request
1009
+ itself is still an ordinary HTTP request to the API host, so that host sees the
1010
+ connection's IP address like any server would — what the *payload* carries is
1011
+ the list above and nothing more.
1012
+
874
1013
  To try it locally, a mock API is included:
875
1014
 
876
1015
  ```sh
@@ -878,10 +1017,130 @@ node demo/mock-api.mjs # http://localhost:8788
878
1017
  # serve the repo root, then open demo/saas.html
879
1018
  ```
880
1019
 
1020
+ ## Changelog
1021
+
1022
+ Client versions. The WordPress plugin tracks the same numbers and keeps its own
1023
+ notes in
1024
+ [`plugins/wordpress/consentkit/readme.txt`](https://github.com/vermoh/ConsentKit/blob/main/plugins/wordpress/consentkit/readme.txt).
1025
+
1026
+ ### 0.5.12
1027
+ - **Services in the preferences panel are collapsed by default and compact.**
1028
+ The group's «N services · M cookies» line became a disclosure button; opening
1029
+ it reveals the services and the group's cookie table. Each service row is one
1030
+ line — name, vendor, switch — with the purpose, the privacy link and its
1031
+ cookie list behind a small «Details» disclosure inside the row.
1032
+ - **A service in the «necessary» group has no switch.** It carries the «always
1033
+ on» badge instead, `allowedService()` is unconditionally `true` for it, and a
1034
+ refusal for such a service is stripped from the denial map on read and write.
1035
+ - Tracker database: `c.bing.com`.
1036
+
1037
+ ### 0.5.11
1038
+ - The preferences panel and the floating button follow the **banner's** buttons
1039
+ by role, with no theme settings of their own: «save choice» is styled as
1040
+ `accept`, the panel's own accept and reject as `settings`, and the floating
1041
+ button takes the `accept` fill (its border colour when `accept` is `outline`).
1042
+
1043
+ ### 0.5.10
1044
+ - **A colour the site owner set is painted as set.** The 4.5:1 text rule and the
1045
+ 3:1 border rule now correct only *derived* colours; an explicit `fg`, `border`
1046
+ or `onAccent` is left alone however low it measures.
1047
+ - `theme.light.onAccent` — the light-mode mirror of `theme.dark.onAccent`.
1048
+ - The debug panel reports the measured ratio and warns, instead of claiming a
1049
+ value was fixed automatically.
1050
+
1051
+ ### 0.5.9
1052
+ - Tracker database: +42 entries — YouTube, Vimeo, Facebook and Instagram embeds,
1053
+ chats and CRM (Freshworks, Viber, Telegram, Bitrix24, amoCRM), forms and
1054
+ scheduling (Calendly, Typeform), payments (Stripe, PayPal, paynet.md, MAIB),
1055
+ Sentry, 999.md. Vercel and Netlify are classified as infrastructure.
1056
+ - The debug panel is honest about Consent Mode: "no cookies, but the page
1057
+ address and browser type are sent".
1058
+
1059
+ ### 0.5.8
1060
+ - **Services** (`services`): a site declares individual third parties, each of
1061
+ which gets its own toggle inside its category group in the panel, with its own
1062
+ vendor, purpose, privacy link and cookies. A visitor can accept a category and
1063
+ still refuse one service; the refusal survives the group being switched off
1064
+ and on again. Refusals are stored in `ck_consent` as `services: { id: false }`
1065
+ and, in SaaS mode, reported in the beacon's `services` field.
1066
+ - `ConsentKit.allowedService(id)`.
1067
+
1068
+ ### 0.5.7
1069
+ - Placeholders for blocked embeds: a card in place of a held-back video or map,
1070
+ with an "Allow and show" button that grants just that category
1071
+ (`blocking.placeholders`, on by default).
1072
+ - `#ck-settings` in a page address opens the preferences panel;
1073
+ `ConsentKit.openSettings()` is safe to call before the UI has mounted.
1074
+ - `texts.detailsAction: "declaration"` and `texts.declarationUrl`.
1075
+
1076
+ ### 0.5.6
1077
+ - The attribution line follows the banner's language
1078
+ (`branding.poweredBy.texts`).
1079
+
1080
+ ### 0.5.5
1081
+ - Banner and panel texts rewritten in plain language (ru, ro, en).
1082
+
1083
+ ### 0.5.4
1084
+ - Database: Google Maps is `functional`; Tilda platform services and
1085
+ `fonts.google.com` are infrastructure; Google Ads pings
1086
+ (`/pagead/1p-user-list`, `/ads/ga-audiences`) are `marketing`.
1087
+
1088
+ ### 0.5.3
1089
+ - The banner re-resolves the page font after full load (on Tilda a reload from
1090
+ cache left the banner in Times), and falls back to the system stack when the
1091
+ page font cannot be determined.
1092
+
1093
+ ### 0.5.2
1094
+ - The banner takes its font from the page's real text rather than from `body`.
1095
+
1096
+ ### 0.5.1
1097
+ - Database: general CDNs (gstatic.com, aspnetcdn.com, kxcdn.com, jsDelivr,
1098
+ cdnjs, unpkg, CloudFront and others) are infrastructure, not trackers; Google
1099
+ reCAPTCHA is `necessary`; Searchanise and iuteCredit are `functional`.
1100
+
1101
+ ### 0.5.0
1102
+ - **The theme engine.** `theme.font` (the banner takes the site's font by
1103
+ default, `'system'` restores the old stack), `theme.radius: { card, button }`
1104
+ in px, and `theme.buttons` — variant, background, text, border and border
1105
+ width per button. Accept and reject are always equal in size, weight and
1106
+ variant, by construction. Contrast is checked automatically: text below 4.5:1
1107
+ and borders below 3:1 are corrected.
1108
+ - `texts.policyUrl` and `texts.detailsAction` (`policy` / `settings` / `hide`):
1109
+ «Learn more» now does something. Before 0.5.0 it was `<a href="#">` with no
1110
+ handler — any site on 0.4.x or earlier has a dead link.
1111
+ - The corner card gained its reference geometry: up to 540px wide, 24px padding,
1112
+ buttons in a row with an 8px gap and stacked on narrow screens.
1113
+
1114
+ ### 0.4.1
1115
+ - Infrastructure list (`ConsentKit._infra()`): builder CDNs, Google Fonts and
1116
+ captcha, never intercepted by strict mode.
1117
+ `static.cloudflareinsights.com` is deliberately excluded — it is analytics.
1118
+
1119
+ ### 0.4.0
1120
+ - **Strict mode** (`blocking.mode: 'strict'`): before consent, any third-party
1121
+ script or iframe that is not same-site, allow-listed or in the built-in
1122
+ allowlist is held back and filed as `marketing`.
1123
+ - `ConsentKit._extendHostDb()` and the `hostdb` config key.
1124
+ - `<iframe src>` is intercepted, not just scripts.
1125
+
1126
+ ### 0.3.6
1127
+ - Branding moved to `src/ck-ui-branding.js`, so `--no-branding` drops the code
1128
+ as well as the config (~26 KB off a block).
1129
+
1130
+ ### 0.3.5
1131
+ - **Server-side tracker markup in the WordPress plugin, on by default** — the
1132
+ one case the browser engine cannot cover. The database shipped to PHP is
1133
+ generated from `src/ck-core.js`, and a test fails when the two drift.
1134
+ - The debug panel became a lazily-loaded file behind a ~5 KB loader.
1135
+
1136
+ ### 0.3.2
1137
+ - The GTM container is no longer blocked: tags inside it obey Consent Mode, and
1138
+ blocking the container breaks that. `/gtag/js` is still blocked by path.
1139
+
881
1140
  ## Project status
882
1141
 
883
- **This is a prototype (v0.3), not a released product.** It is honest about what
884
- has been verified and what has not.
1142
+ **This is a prototype (v0.5.12), not a released product.** It is honest about
1143
+ what has been verified and what has not.
885
1144
 
886
1145
  ### Verified
887
1146
 
@@ -893,6 +1152,11 @@ has been verified and what has not.
893
1152
  - `dataLayer` event trace for consent restore, upgrade and withdrawal.
894
1153
  - npm entry points and TypeScript types: syntax and import smoke tests in Node
895
1154
  without a DOM.
1155
+ - An automated suite runs under `npm test` (`node --test test/*.test.mjs`):
1156
+ blocking engine, tracker-database classification and its PHP export, services,
1157
+ the theme and contrast arithmetic, the settings panel, branding, the debug
1158
+ loader and panel, the generated site, and a version guard that fails when
1159
+ `package.json`, `src/ck-core.js` and the blocks in `ready/` drift apart.
896
1160
  - PHP files of the WordPress plugin pass `php -l` on 7.4, 8.3 and 8.5.
897
1161
  - The server-side rewriting engine has its own suite of 61 cases
898
1162
  (`plugins/wordpress/consentkit/tests/rewrite.test.php`), green on PHP 7.4,
@@ -910,16 +1174,20 @@ has been verified and what has not.
910
1174
  and structurally modelled on the documented export format, but Tag Manager has
911
1175
  not accepted it in practice; some field names (notably GA4 config
912
1176
  `measurementId` vs `tagId`) may need correction on first import.
913
- - **Translations beyond `en`, `ru`, `de` and `fr` are drafts.** They are usable
914
- but have not been reviewed by native speakers. Legal wording "Reject all",
915
- "always active" should be checked by someone who knows the local regulator's
916
- language before you rely on it.
1177
+ - **The 32 locales of the language pack are drafts.** Only `en` and `ru` — the
1178
+ two built into the UI are authored rather than translated. The rest are
1179
+ usable but have not been reviewed by native speakers (the weakest are `mt`,
1180
+ `ga`, `is`, `sq`, `mk`; `pl` mixes politeness forms). Legal wording — "Reject
1181
+ all", "always active" — should be checked by someone who knows the local
1182
+ regulator's language before you rely on it.
917
1183
  - **There is no server-side consent log.** Consent lives only in the visitor's
918
1184
  browser (cookie plus `localStorage`). GDPR accountability may require you to
919
1185
  be able to *demonstrate* that consent was given; that record-keeping is not
920
1186
  part of this prototype and you would have to build it yourself.
921
- - No automated test suite and no CI beyond the Pages deployment; verification is
922
- the manual smoke checklist in [CONTRIBUTING.md](CONTRIBUTING.md).
1187
+ - **No end-to-end browser tests and no CI beyond the Pages deployment.** The
1188
+ `npm test` suite runs in Node against DOM stubs, not a real browser; browser
1189
+ verification is still the manual smoke checklist in
1190
+ [CONTRIBUTING.md](https://github.com/vermoh/ConsentKit/blob/main/CONTRIBUTING.md).
923
1191
  - Not audited by a lawyer. ConsentKit is a technical building block, not legal
924
1192
  advice, and it cannot make a site compliant on its own — your privacy policy,
925
1193
  your cookie inventory and your record-keeping are still yours.
@@ -927,7 +1195,7 @@ has been verified and what has not.
927
1195
  ### Contributing
928
1196
 
929
1197
  Structure of the repository, the GDPR invariants that must not change, and how
930
- to run the checks: [CONTRIBUTING.md](CONTRIBUTING.md).
1198
+ to run the checks: [CONTRIBUTING.md](https://github.com/vermoh/ConsentKit/blob/main/CONTRIBUTING.md).
931
1199
 
932
1200
  ## License
933
1201
 
@@ -935,8 +1203,8 @@ Copyright (c) 2026 E-COM CONSULT PLUS.
935
1203
 
936
1204
  | Part | Licence |
937
1205
  |---|---|
938
- | Client (`src/`), npm package, inline builder, demo | [MIT](LICENSE) |
939
- | WordPress plugin (`plugins/wordpress/consentkit/`) | [GPL-2.0-or-later](plugins/wordpress/consentkit/LICENSE) |
1206
+ | Client (`src/`), npm package, inline builder, demo | [MIT](https://github.com/vermoh/ConsentKit/blob/main/LICENSE) |
1207
+ | WordPress plugin (`plugins/wordpress/consentkit/`) | [GPL-2.0-or-later](https://github.com/vermoh/ConsentKit/blob/main/plugins/wordpress/consentkit/LICENSE) |
940
1208
 
941
1209
  The client is MIT so it can be embedded anywhere without licence friction. The
942
1210
  WordPress plugin ships under GPLv2+ because the WordPress ecosystem effectively
@@ -944,4 +1212,4 @@ requires it; MIT permits the plugin to bundle copies of the client in its
944
1212
  `assets/` directory.
945
1213
 
946
1214
  Contributions require a `Signed-off-by` line (DCO) — see
947
- [CONTRIBUTING.md](CONTRIBUTING.md).
1215
+ [CONTRIBUTING.md](https://github.com/vermoh/ConsentKit/blob/main/CONTRIBUTING.md).
package/npm/index.d.ts CHANGED
@@ -141,6 +141,12 @@ export interface CkThemeConfig {
141
141
  mode?: 'auto' | 'light' | 'dark';
142
142
  /** v0.2. Overrides the built-in dark palette. */
143
143
  dark?: CkDarkTheme;
144
+ /**
145
+ * v0.5.10. The light-mode mirror of `dark`, for one value: an `onAccent` the
146
+ * owner set for the filled buttons' text in light mode. A typed value is
147
+ * painted as typed (contrast is a warning, never a correction).
148
+ */
149
+ light?: { onAccent?: string };
144
150
  }
145
151
 
146
152
  /** v0.5.0 (SPEC V1.6 §2). Copy and the «Learn more» control. */
@@ -28,7 +28,7 @@ export function undecidedState() {
28
28
  */
29
29
  export function createStub() {
30
30
  const stub = {
31
- version: '0.5.10',
31
+ version: '0.5.12',
32
32
  config: {},
33
33
  init: function () { return undecidedState(); },
34
34
  allowed: function (cat) { return cat === 'necessary'; },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecomconsult/consentkit",
3
- "version": "0.5.10",
3
+ "version": "0.5.12",
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
@@ -108,6 +108,7 @@
108
108
  'ct.pinterest.com': 'marketing', // subdomain: pinterest.com is a normal site
109
109
  'sc-static.net': 'marketing', // Snapchat pixel CDN
110
110
  'bat.bing.com': 'marketing', // subdomain: bing.com is a normal site
111
+ 'c.bing.com': 'marketing', // Bing pixel (/c.gif) fired by UET and Clarity
111
112
  'adroll.com': 'marketing',
112
113
  'hs-analytics.net': 'marketing', // HubSpot tracking
113
114
  'hs-scripts.com': 'marketing',
@@ -796,13 +797,26 @@
796
797
  // key that is not a service id — is dropped rather than trusted: this record
797
798
  // is attacker-writable (it lives in a cookie and in localStorage), and a
798
799
  // malformed entry must not be able to widen or narrow what gets blocked.
800
+ //
801
+ // 0.5.12 — a service in the `necessary` group is ALSO dropped, on read and on
802
+ // write alike. Such a service has no switch in the panel (the group is always
803
+ // on, so a control that could only ever refuse it would be a lie), and
804
+ // allowedService() answers `true` for it unconditionally — so a denial for one
805
+ // could only ever be dead weight that survives in the record and confuses the
806
+ // beacon and the debug report. The filter is safe here because init() runs
807
+ // buildServices() BEFORE loadRecord(), so SERVICE_BY_ID is already populated
808
+ // when a stored map is read back; an id the registry does not know is left
809
+ // alone, exactly as before, because nothing can be claimed about its category.
799
810
  function readServices(raw) {
800
811
  var out = {};
801
812
  if (!isPlainObject(raw)) { return out; }
802
813
  var keys = Object.keys(raw);
803
814
  for (var i = 0; i < keys.length && i < SERVICE_MAX; i++) {
804
815
  var k = keys[i];
805
- if (raw[k] === false && SERVICE_ID_RE.test(k)) { out[k] = false; }
816
+ if (raw[k] !== false || !SERVICE_ID_RE.test(k)) { continue; }
817
+ var s = SERVICE_BY_ID[k];
818
+ if (s && s.category === 'necessary') { continue; }
819
+ out[k] = false;
806
820
  }
807
821
  return out;
808
822
  }
@@ -1201,6 +1215,14 @@
1201
1215
  // silent block of something the visitor was never asked about.
1202
1216
  function serviceDenied(id) {
1203
1217
  if (!id || typeof id !== 'string') { return false; }
1218
+ /* 0.5.12 — a service in the `necessary` group is NEVER denied, whatever the
1219
+ map says. readServices() already strips such an entry on every read and
1220
+ write, so this is the single choke point that also covers a map mutated
1221
+ by some other path: deniedForSrc() (the blocking patches), and
1222
+ deniedServiceIds() (the beacon field and the debug report) both ask this
1223
+ question, so answering it once here keeps the three from drifting. */
1224
+ var s = SERVICE_BY_ID[id];
1225
+ if (s && s.category === 'necessary') { return false; }
1204
1226
  return state.services[id] === false;
1205
1227
  }
1206
1228
 
@@ -1212,6 +1234,13 @@
1212
1234
  function allowedService(id) {
1213
1235
  var s = SERVICE_BY_ID[id];
1214
1236
  if (!s) { return true; } // unknown id: nothing to withhold
1237
+ /* 0.5.12 — a `necessary` service is allowed, full stop. The group cannot be
1238
+ switched off (allowed('necessary') is always true), the panel renders no
1239
+ switch for it, and readServices() drops any denial that reaches the map.
1240
+ This is the belt to that braces: a denial arriving by some path neither
1241
+ covers — a stale in-memory map, a caller reaching past accept() — must
1242
+ still not be able to hold back something the visitor cannot re-enable. */
1243
+ if (s.category === 'necessary') { return true; }
1215
1244
  if (!allowed(s.category)) { return false; }
1216
1245
  return !serviceDenied(id);
1217
1246
  }
@@ -1229,7 +1258,10 @@
1229
1258
  function deniedServiceIds() {
1230
1259
  var out = [];
1231
1260
  for (var i = 0; i < SERVICES.length; i++) {
1232
- if (state.services[SERVICES[i].id] === false) { out.push(SERVICES[i].id); }
1261
+ // Through serviceDenied(), not the raw map: that is the one place the
1262
+ // `necessary` rule lives, so the beacon and the debug report can never
1263
+ // name a service the blocking engine is in fact allowing.
1264
+ if (serviceDenied(SERVICES[i].id)) { out.push(SERVICES[i].id); }
1233
1265
  }
1234
1266
  return out;
1235
1267
  }
@@ -1941,7 +1973,7 @@
1941
1973
  // Public API
1942
1974
  // ---------------------------------------------------------------------------
1943
1975
  var ConsentKit = {
1944
- version: '0.5.10',
1976
+ version: '0.5.12',
1945
1977
  config: config,
1946
1978
 
1947
1979
  init: function (userConfig) {
package/src/ck-locales.js CHANGED
@@ -584,6 +584,10 @@
584
584
  ckCount: ['{n} cookie', '{n} cookie-uri', '{n} de cookie-uri'],
585
585
  svcPolicy: 'Politica',
586
586
  svcCookies: 'Ce cookie-uri pune',
587
+ // 0.5.12 — dezvăluirea din rând. NU `more`, care aici este o propoziție
588
+ // («Aflați mai multe»), nu un control de rând.
589
+ svcDetails: 'Detalii',
590
+ svcListLabel: 'Servicii și cookie-uri din acest grup',
587
591
  cat: {
588
592
  necessary: { title: 'Necesare', desc: 'Sunt necesare pentru funcționarea site-ului: autentificare, securitate, reținerea alegerii dumneavoastră. Nu pot fi dezactivate.' },
589
593
  functional: { title: 'Funcționale', desc: 'Rețin alegerile dumneavoastră: limba, coșul, chatul.' },
package/src/ck-ui.js CHANGED
@@ -44,6 +44,14 @@
44
44
  // The service's own cookies, listed under it rather than in the group's
45
45
  // «Which cookies» table.
46
46
  svcCookies: 'Cookies it sets',
47
+ // 0.5.12 — the per-row disclosure. A row is one line until this is opened;
48
+ // the purpose, the policy link and the service's cookie table live behind
49
+ // it. NOT `more`: that string is the banner's «Подробнее» and reads
50
+ // «Aflați mai multe» in ro, which is a sentence, not a row control.
51
+ svcDetails: 'Details',
52
+ // The group's own disclosure: how many services are hidden behind the
53
+ // «N services · M cookies» button. Only ever read by a screen reader.
54
+ svcListLabel: 'Services and cookies in this group',
47
55
  // SPEC V1.10 §2 — the blocked-embed placeholder. {host} is the vendor
48
56
  // label when the database knows one and the bare host otherwise; {cat} is
49
57
  // the localized category title, taken from cat.<name>.title below, so the
@@ -97,6 +105,8 @@
97
105
  ckCount: ['{n} cookie', '{n} cookie', '{n} cookie'],
98
106
  svcPolicy: 'Политика',
99
107
  svcCookies: 'Какие cookie ставит',
108
+ svcDetails: 'Подробнее',
109
+ svcListLabel: 'Сервисы и cookie этой группы',
100
110
  phText: 'Здесь содержимое от {host}. Оно загрузится после согласия на «{cat}».',
101
111
  phAllow: 'Разрешить и показать',
102
112
  phSettings: 'Настроить cookie',
@@ -142,7 +152,12 @@
142
152
  // branch in buildStrings() — listing them here would be a bug, because this
143
153
  // loop only copies values that are `typeof === 'string'` and would leave
144
154
  // every external locale on the English plurals.
145
- 'svcPolicy', 'svcCookies'
155
+ 'svcPolicy', 'svcCookies',
156
+ // 0.5.12 — the per-row «Подробнее» disclosure and the accessible name of the
157
+ // group's own one. Both are plain strings, so they belong here: a key left
158
+ // out of this list is `undefined` for all 32 external locales and renders
159
+ // the literal word "undefined" on the card.
160
+ 'svcDetails', 'svcListLabel'
146
161
  ];
147
162
 
148
163
  // Plural-form keys, filled separately from STR_KEYS (see above).
@@ -420,15 +435,11 @@
420
435
  'border-color:var(--ck-reject-bd);border-width:var(--ck-reject-bw)}',
421
436
  '.ck-btn--settings{background:var(--ck-settings-bg);color:var(--ck-settings-fg);',
422
437
  'border-color:var(--ck-settings-bd);border-width:var(--ck-settings-bw)}',
423
- /* Kept for the preferences panel's own footer, which is not themable. */
438
+ /* Unused by the UI since 0.5.11: the preferences panel's footer moved to
439
+ the accept/settings role classes above, so it follows the banner. Kept
440
+ as a compatibility shim for integrator CSS and older inline blocks that
441
+ still name these classes — nothing in this file emits them any more. */
424
442
  '.ck-btn--filled{background:var(--ck-accent);border-color:var(--ck-accent);color:var(--ck-on-accent)}',
425
- /* Live, not a fallback: these paint the preferences panel's own Accept
426
- all / Reject all. Transparent fill means the LABEL sits on the card, so
427
- it takes --ck-link like every other accent-coloured text; the BORDER is
428
- non-text and answers to 3:1, so it keeps the raw accent. The footer's
429
- background is --ck-soft rather than --ck-bg, but soft is a 5% ink mix of
430
- the card — the ratio difference is well inside the noise, and a second
431
- link token per background would break «один код — одни числа». */
432
443
  '.ck-btn--outline{background:transparent;border-color:var(--ck-accent);color:var(--ck-link)}',
433
444
  '.ck-btn--ghost{min-width:0;border-color:var(--ck-line);color:var(--ck-ink);font-weight:500}',
434
445
 
@@ -465,21 +476,50 @@
465
476
  /* SPEC V1.12 §3 — «N сервисов · M cookie». --ck-muted, like every other
466
477
  secondary label on the card, and it is measured for AA against the card
467
478
  background by the same rule the description above answers to. */
479
+ /* 0.5.12 — the counter is now the group's disclosure BUTTON. It keeps the
480
+ muted label's size and colour (it is still a secondary label, measured
481
+ for AA against the card background by the same rule as the description),
482
+ and gains a caret and a hit area. `border:0;background:transparent` is
483
+ needed because the panel's reset styles buttons, not because a link is
484
+ being faked — it is a real button and the global :focus-visible rule
485
+ gives it a visible ring. */
468
486
  '.ck-cat__count{font-size:12px;font-weight:500;color:var(--ck-muted)}',
487
+ '.ck-cat__toggle{border:0;background:transparent;padding:2px 0;cursor:pointer;',
488
+ 'display:inline-flex;align-items:center;gap:5px;font-family:inherit;text-align:left}',
489
+ '.ck-cat__caret{width:0;height:0;flex:none;border:4px solid transparent;',
490
+ 'border-left-color:currentColor;border-right:0}',
491
+ '.ck-cat__toggle[aria-expanded="true"] .ck-cat__caret{transform:rotate(90deg)}',
469
492
 
470
- /* ---- services inside a group (SPEC V1.12 §3) ---- */
493
+ /* ---- services inside a group (SPEC V1.12 §3, compacted in 0.5.12) ---- */
471
494
  /* Indented and rule-separated so the nesting reads without colour: a
472
495
  service belongs to the group above it, and its own cookie table belongs
473
496
  to it. The left border is the only decoration; everything else is
474
497
  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)}',
498
+ '.ck-cat__region{margin-top:12px}',
499
+ '.ck-svcs{padding-left:12px;border-left:2px solid var(--ck-line)}',
500
+ '.ck-svc{padding:8px 0;border-bottom:1px solid var(--ck-line)}',
477
501
  '.ck-svc:first-child{padding-top:2px}',
478
502
  '.ck-svc:last-child{border-bottom:0;padding-bottom:2px}',
479
- '.ck-svc__top{display:flex;gap:12px;align-items:flex-start}',
503
+ /* One line: name, vendor, then the switch pinned to the right. `center`
504
+ rather than flex-start, because the row is a single line of text now and
505
+ the switch reads as belonging to it. */
506
+ '.ck-svc__top{display:flex;gap:10px;align-items:center}',
480
507
  '.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)}',
508
+ /* The name and the vendor share a line and WRAP rather than truncate: at
509
+ 320px a long «Google Analytics · Google Ireland Limited» becomes two
510
+ lines and the switch stays where it is, still reachable. */
511
+ '.ck-svc__name{display:flex;align-items:baseline;gap:6px;flex-wrap:wrap;',
512
+ 'font-size:14px;font-weight:600;line-height:1.35}',
513
+ '.ck-svc__vendor{margin:0;font-size:12.5px;font-weight:400;color:var(--ck-muted)}',
514
+ /* «всегда активны» on a necessary service's row, where its switch used to
515
+ be: the same pill the group header wears, one step smaller. */
516
+ '.ck-svc__badge{font-size:11px;font-weight:500;color:var(--ck-muted);',
517
+ 'border:1px solid var(--ck-line);border-radius:999px;padding:0 7px}',
518
+ /* The row's own «Подробнее». Tighter than a group's <details> — it sits
519
+ inside a row, not between two of them. */
520
+ '.ck-svc__det{margin-top:2px}',
521
+ '.ck-svc__det>summary{font-size:12.5px}',
522
+ '.ck-svc__body{padding:2px 0 4px}',
483
523
  '.ck-svc__desc{margin:4px 0 0;font-size:13px;color:var(--ck-muted)}',
484
524
  /* --ck-link, not --ck-accent: accent-coloured TEXT goes through the same
485
525
  >= 4.5:1 rule as «Подробнее» — see the note on the `a{}` rule above. */
@@ -516,8 +556,12 @@
516
556
  '.ck-empty{margin:8px 0 0;font-size:13px;color:var(--ck-muted)}',
517
557
 
518
558
  /* ---- floating button ---- */
559
+ /* 0.5.11 — the floating button wears the ACCEPT button's colours, so the
560
+ one control that outlives the banner still reads as the owner's brand.
561
+ Both tokens are resolved once in resolveButtonStyles (`fab`) and only
562
+ read here — «один код — одни числа». */
519
563
  '.ck-fab{position:fixed;left:16px;bottom:16px;z-index:2147482999;width:48px;height:48px;',
520
- 'border-radius:50%;border:1px solid var(--ck-line);background:var(--ck-bg);color:var(--ck-link);',
564
+ 'border-radius:50%;border:1px solid var(--ck-line);background:var(--ck-fab-bg);color:var(--ck-fab-fg);',
521
565
  'display:inline-flex;align-items:center;justify-content:center;padding:0}',
522
566
  '.ck-fab svg{width:24px;height:24px;display:block}',
523
567
 
@@ -898,7 +942,7 @@
898
942
 
899
943
  /* Config values are interpolated into the TEXT of a generated stylesheet, so
900
944
  an unvalidated value can close the declaration and open rules of its own
901
- ("10px;}.ck-btn--filled{display:none" hides "Reject all"). config.theme is
945
+ ("10px;}.ck-btn--reject{display:none" hides "Reject all"). config.theme is
902
946
  not trusted input: in standalone mode it comes straight from the embedding
903
947
  page or an integrator's admin panel, with no server-side validation
904
948
  anywhere in the path. So every value is matched against a strict grammar
@@ -1271,9 +1315,10 @@
1271
1315
  };
1272
1316
 
1273
1317
  /* The accent as TEXT on the card. Same >= 4.5 rule, same function, one
1274
- number: links, «Подробнее», the cookie-table summary and the floating
1275
- button's icon all read the token this produces, so none of them can
1276
- drift from what the debug panel and the cabinet quote. Note the
1318
+ number: links, «Подробнее» and the cookie-table summary all read the
1319
+ token this produces, so none of them can drift from what the debug
1320
+ panel and the cabinet quote. (The floating button left this token in
1321
+ 0.5.11 — it now follows the accept button; see `fab` below.) Note the
1277
1322
  measurement is against cardBg — an accent that is fine inside a filled
1278
1323
  button can still be unreadable on the card behind it.
1279
1324
 
@@ -1414,6 +1459,46 @@
1414
1459
  }
1415
1460
  out.buttons[role] = rec;
1416
1461
  }
1462
+
1463
+ /* The floating button (0.5.11). It is a filled circle that outlives the
1464
+ banner, and the owner's rule is that it wears the ACCEPT button's
1465
+ colours — no setting of its own.
1466
+
1467
+ accept FILLED: take its bg/fg verbatim. That inherits the 0.5.10 rule
1468
+ for free — a typed low-contrast fg stays exactly as typed here too, and
1469
+ is reported through `low` rather than corrected.
1470
+
1471
+ accept OUTLINE: there is no fill to borrow, so the button's BORDER
1472
+ colour becomes the circle and the CARD colour becomes the icon — the
1473
+ outline button's two real colours, swapped. Both are derived for the
1474
+ fab (nobody typed "the floating button is this colour"), so the icon
1475
+ goes through the >= 4.5 rule; ensureContrast picks the readable member
1476
+ of the pair, which on a dark border is the card itself and on a pale
1477
+ one steps away from it. When accept's border is itself derived it is
1478
+ already the accent stepped to 3:1, so this is also the accent/on-accent
1479
+ fallback the brief describes. */
1480
+ var acc = out.buttons.accept;
1481
+ if (acc.variant === 'filled') {
1482
+ out.fab = {
1483
+ bg: acc.bg,
1484
+ fg: acc.fg,
1485
+ ratio: acc.ratio,
1486
+ adjusted: acc.adjusted,
1487
+ low: acc.low,
1488
+ against: acc.bg
1489
+ };
1490
+ } else {
1491
+ var fabBg = acc.border;
1492
+ var fabRes = ensureContrast(cardBg, fabBg, 4.5);
1493
+ out.fab = {
1494
+ bg: fabBg,
1495
+ fg: fabRes.color,
1496
+ ratio: fabRes.ratio,
1497
+ adjusted: fabRes.adjusted,
1498
+ low: below(fabRes.ratio, 4.5),
1499
+ against: fabBg
1500
+ };
1501
+ }
1417
1502
  return out;
1418
1503
  }
1419
1504
 
@@ -1438,6 +1523,12 @@
1438
1523
  // same way as __buttons: one pass per mode, so the light and dark blocks
1439
1524
  // each carry their own contrast-checked link colour.
1440
1525
  if (p.__link) d.push('--ck-link:' + p.__link.color);
1526
+ // The floating button's pair, resolved from the accept button in the same
1527
+ // pass, so light and dark each carry their own (0.5.11).
1528
+ if (p.__fab) {
1529
+ d.push('--ck-fab-bg:' + p.__fab.bg);
1530
+ d.push('--ck-fab-fg:' + p.__fab.fg);
1531
+ }
1441
1532
  var btns = p.__buttons;
1442
1533
  if (btns) {
1443
1534
  for (var i = 0; i < BTN_ROLES.length; i++) {
@@ -1511,17 +1602,20 @@
1511
1602
  light.__buttons = lightBtn.buttons;
1512
1603
  dark.__buttons = darkBtn.buttons;
1513
1604
 
1514
- // `--ck-on-accent` paints the text of every filled surface that is NOT a
1515
- // banner buttonthe panel's «Сохранить выбор» and the floating button —
1516
- // straight from the palette. A derived onAccent therefore has to pass the
1517
- // same >= 4.5 rule against the accent, or a white accent gets white text
1518
- // (the owner saw exactly that). Done AFTER the buttons are resolved, so
1605
+ // `--ck-on-accent` paints the text of the remaining filled surfaces that
1606
+ // are not banner buttonssince 0.5.11 that is the blocked-embed
1607
+ // placeholder's button, the panel foot and the floating button having
1608
+ // moved onto the accept button's own tokens. A derived onAccent still has
1609
+ // to pass the >= 4.5 rule against the accent, or a white accent gets white
1610
+ // text (the owner saw exactly that). Done AFTER the buttons are resolved, so
1519
1611
  // the accept button still records `adjusted:true` for the same correction.
1520
1612
  // An onAccent the owner set is painted as set — the 0.5.10 rule.
1521
1613
  if (!light.onAccentExplicit) light.onAccent = ensureContrast(light.onAccent, light.accent, 4.5).color;
1522
1614
  if (!dark.onAccentExplicit) dark.onAccent = ensureContrast(dark.onAccent, dark.accent, 4.5).color;
1523
1615
  light.__link = lightBtn.link;
1524
1616
  dark.__link = darkBtn.link;
1617
+ light.__fab = lightBtn.fab;
1618
+ dark.__fab = darkBtn.fab;
1525
1619
 
1526
1620
  var mode = theme.mode;
1527
1621
  if (mode !== 'light' && mode !== 'dark') mode = 'auto';
@@ -1658,7 +1752,23 @@
1658
1752
  }
1659
1753
  }
1660
1754
 
1755
+ /* SPEC V1.12 §3, reshaped in 0.5.12 — one service, on ONE line.
1756
+
1757
+ 0.5.8 rendered the name, the vendor, the purpose paragraph, the policy link
1758
+ and a cookie table for every service, all at once. Eight services filled the
1759
+ panel and the visitor scrolled past the switches they came for. The row is
1760
+ now: name (bold) · vendor (muted) · switch, and nothing else until the row's
1761
+ own «Подробнее» is opened — behind which the purpose, the policy link and
1762
+ «Какие cookie ставит (N)» live, unchanged.
1763
+
1764
+ `cat === 'necessary'` renders NO switch. The group is always on and cannot
1765
+ be refused, so a control that could only ever sit at «off» (which is what
1766
+ 0.5.11 showed, because syncGroup() only ever ran for the opt-in groups)
1767
+ tells the visitor a lie about what they can change. The «всегда активны»
1768
+ badge the group header already carries says the true thing instead. */
1661
1769
  function buildService(svc, cat, rows) {
1770
+ var locked = cat === 'necessary';
1771
+
1662
1772
  var wrap = el('div', 'ck-svc');
1663
1773
  var top = el('div', 'ck-svc__top');
1664
1774
  var txt = el('div', 'ck-svc__txt');
@@ -1668,45 +1778,68 @@
1668
1778
  var nameSpan = el('span', null, svc.name);
1669
1779
  nameSpan.id = nameId;
1670
1780
  name.appendChild(nameSpan);
1781
+ // Vendor on the SAME line as the name now, not a paragraph under it. The
1782
+ // row wraps rather than truncates, so a long pair still reads at 320px.
1783
+ if (svc.vendor) name.appendChild(el('span', 'ck-svc__vendor', svc.vendor));
1784
+ if (locked) name.appendChild(el('span', 'ck-svc__badge', T.alwaysOn));
1671
1785
  txt.appendChild(name);
1672
1786
 
1673
- if (svc.vendor) txt.appendChild(el('p', 'ck-svc__vendor', svc.vendor));
1787
+ top.appendChild(txt);
1674
1788
 
1675
- var descId = null;
1676
- var purpose = servicePurpose(svc);
1677
- if (purpose) {
1678
- descId = nameId + '-desc';
1679
- var p = el('p', 'ck-svc__desc', purpose);
1680
- p.id = descId;
1681
- txt.appendChild(p);
1682
- }
1683
-
1684
- /* «Политика»target=_blank rel=noopener, per §3. The URL is already
1685
- http(s)-validated by the core's normalizeService(), which is where a
1686
- javascript: address is dropped; nothing unvalidated reaches an href. */
1687
- if (svc.privacyUrl) {
1688
- var a = el('a', 'ck-svc__policy', T.svcPolicy);
1689
- a.href = svc.privacyUrl;
1690
- a.target = '_blank';
1691
- a.rel = 'noopener noreferrer';
1692
- // The link text is the same word on every row, so a screen reader needs
1693
- // the service name to tell them apart.
1694
- a.setAttribute('aria-label', T.svcPolicy + ' — ' + svc.name);
1695
- txt.appendChild(a);
1696
- }
1697
-
1698
- var sw = makeServiceSwitch(svc, cat);
1699
- sw.setAttribute('aria-labelledby', nameId);
1700
- if (descId) sw.setAttribute('aria-describedby', descId);
1701
- if (!serviceSwitches[cat]) serviceSwitches[cat] = [];
1702
- serviceSwitches[cat].push(sw);
1789
+ /* No switch for a necessary service — and no entry in serviceSwitches[],
1790
+ which is what keeps readSwitches() from ever writing such an id into the
1791
+ denial map. (It iterates OPT_IN, so it could not today; not registering
1792
+ the switch means it still cannot if that loop ever widens.) */
1793
+ if (!locked) {
1794
+ var sw = makeServiceSwitch(svc, cat);
1795
+ /* aria-labelledby only. 0.5.8 also pointed aria-describedby at the purpose
1796
+ paragraph; in 0.5.12 that paragraph lives inside a CLOSED <details>, and
1797
+ a description a screen reader cannot reach until the visitor opens
1798
+ something else is worse than none the name and the switch's own state
1799
+ are what the control has to announce. The purpose is still there, one
1800
+ «Подробнее» away, as its own readable text. */
1801
+ sw.setAttribute('aria-labelledby', nameId);
1802
+ if (!serviceSwitches[cat]) serviceSwitches[cat] = [];
1803
+ serviceSwitches[cat].push(sw);
1804
+ top.appendChild(sw);
1805
+ }
1703
1806
 
1704
- top.appendChild(txt);
1705
- top.appendChild(sw);
1706
1807
  wrap.appendChild(top);
1707
1808
 
1809
+ /* Everything else goes behind the row's own disclosure. <details>/<summary>
1810
+ rather than a button: the keyboard behaviour, the focus ring and the
1811
+ expanded state come from the browser, and it is the same control the
1812
+ cookie tables have used since 0.5.7. */
1813
+ var purpose = servicePurpose(svc);
1708
1814
  var own = cookieRowsForService(rows, svc);
1709
- if (own.length) wrap.appendChild(cookieTable(own, T.svcCookies + ' (' + own.length + ')'));
1815
+ if (purpose || svc.privacyUrl || own.length) {
1816
+ var det = el('details', 'ck-det ck-svc__det');
1817
+ var sum = el('summary');
1818
+ sum.appendChild(document.createTextNode(T.svcDetails));
1819
+ // The word is the same on every row, so a screen reader needs the service
1820
+ // name to tell one «Подробнее» from the next.
1821
+ sum.setAttribute('aria-label', T.svcDetails + ' — ' + svc.name);
1822
+ det.appendChild(sum);
1823
+
1824
+ var body = el('div', 'ck-svc__body');
1825
+ if (purpose) body.appendChild(el('p', 'ck-svc__desc', purpose));
1826
+
1827
+ /* «Политика» — target=_blank rel=noopener, per §3. The URL is already
1828
+ http(s)-validated by the core's normalizeService(), which is where a
1829
+ javascript: address is dropped; nothing unvalidated reaches an href. */
1830
+ if (svc.privacyUrl) {
1831
+ var a = el('a', 'ck-svc__policy', T.svcPolicy);
1832
+ a.href = svc.privacyUrl;
1833
+ a.target = '_blank';
1834
+ a.rel = 'noopener noreferrer';
1835
+ a.setAttribute('aria-label', T.svcPolicy + ' — ' + svc.name);
1836
+ body.appendChild(a);
1837
+ }
1838
+
1839
+ if (own.length) body.appendChild(cookieTable(own, T.svcCookies + ' (' + own.length + ')'));
1840
+ det.appendChild(body);
1841
+ wrap.appendChild(det);
1842
+ }
1710
1843
 
1711
1844
  return wrap;
1712
1845
  }
@@ -1748,6 +1881,27 @@
1748
1881
  return det;
1749
1882
  }
1750
1883
 
1884
+ /* 0.5.12 — the group's disclosure button.
1885
+
1886
+ «N сервисов · M cookie» was a static label in 0.5.8; it is now the control
1887
+ that reveals the group's services and its cookie list, COLLAPSED by default.
1888
+ A real <button aria-expanded> rather than a <details>, because the panel
1889
+ already owns the region it toggles — the services list and the loose-cookie
1890
+ table are siblings that must move together, and wrapping both in a
1891
+ <details> would put a second summary line above the one the header already
1892
+ has. Keyboard and focus come free with a button; the ring is the panel's
1893
+ own :focus-visible rule. */
1894
+ function makeGroupToggle(cat, label, regionId) {
1895
+ var b = el('button', 'ck-cat__count ck-cat__toggle');
1896
+ b.type = 'button';
1897
+ b.setAttribute('aria-expanded', 'false');
1898
+ b.setAttribute('aria-controls', regionId);
1899
+ b.appendChild(el('span', 'ck-cat__caret'));
1900
+ b.appendChild(document.createTextNode(label));
1901
+ b.dataset.cat = cat;
1902
+ return b;
1903
+ }
1904
+
1751
1905
  function buildCategory(cfg, cat) {
1752
1906
  var meta = T.cat[cat] || { title: cat, desc: '' };
1753
1907
  var locked = cat === 'necessary';
@@ -1779,9 +1933,11 @@
1779
1933
  «0 сервисов · 3 cookie». The cookie half counts the whole group, services
1780
1934
  and loose rows alike: it answers «сколько cookie в этой группе», which is
1781
1935
  the question the line is there to answer. */
1936
+ var toggle = null;
1937
+ var regionId = nameId + '-svcs';
1782
1938
  if (svcs.length) {
1783
- name.appendChild(el('span', 'ck-cat__count',
1784
- groupCountLabel(svcs.length, rows.length, T, LANG)));
1939
+ toggle = makeGroupToggle(cat, groupCountLabel(svcs.length, rows.length, T, LANG), regionId);
1940
+ name.appendChild(toggle);
1785
1941
  }
1786
1942
 
1787
1943
  var sw = makeSwitch(cat, locked);
@@ -1793,23 +1949,47 @@
1793
1949
  top.appendChild(sw);
1794
1950
  wrap.appendChild(top);
1795
1951
 
1952
+ // «Cookie в этой группе (N)» keeps its old meaning: what is left once each
1953
+ // service has claimed its own. With no services that is the whole table and
1954
+ // the summary line is byte-for-byte what 0.5.7 rendered.
1955
+ var loose = looseCookies(rows, svcs);
1956
+
1796
1957
  if (svcs.length) {
1958
+ /* Both halves live inside the collapsed region: the services and, at the
1959
+ end, the group's own «Какие cookie (N)». `hidden` rather than a class,
1960
+ so the region is out of the accessibility tree and out of the tab order
1961
+ while collapsed — a class that only sets display:none would leave the
1962
+ switches focusable to a keyboard user who cannot see them. */
1963
+ var region = el('div', 'ck-cat__region');
1964
+ region.id = regionId;
1965
+ region.hidden = true;
1966
+
1797
1967
  var list = el('div', 'ck-svcs');
1798
1968
  // A list, so a screen reader announces «3 items» before reading them.
1799
1969
  list.setAttribute('role', 'list');
1970
+ list.setAttribute('aria-label', T.svcListLabel);
1800
1971
  for (var s = 0; s < svcs.length; s++) {
1801
1972
  var item = buildService(svcs[s], cat, rows);
1802
1973
  item.setAttribute('role', 'listitem');
1803
1974
  list.appendChild(item);
1804
1975
  }
1805
- wrap.appendChild(list);
1806
- }
1976
+ region.appendChild(list);
1807
1977
 
1808
- // «Cookie в этой группе (N)» keeps its old meaning: what is left once each
1809
- // service has claimed its own. With no services that is the whole table and
1810
- // the summary line is byte-for-byte what 0.5.7 rendered.
1811
- var loose = looseCookies(rows, svcs);
1812
- if (loose.length) {
1978
+ if (loose.length) {
1979
+ region.appendChild(cookieTable(loose, T.cookiesIn + ' (' + loose.length + ')'));
1980
+ }
1981
+
1982
+ toggle.addEventListener('click', function () {
1983
+ var open = toggle.getAttribute('aria-expanded') === 'true';
1984
+ toggle.setAttribute('aria-expanded', open ? 'false' : 'true');
1985
+ region.hidden = open;
1986
+ });
1987
+
1988
+ wrap.appendChild(region);
1989
+ } else if (loose.length) {
1990
+ // No services: the cookie table sits where it always did, top level and
1991
+ // never behind a disclosure — a pre-0.5.8 config renders as it did in
1992
+ // 0.5.7, which is what «старый конфиг рендерится как раньше» asks for.
1813
1993
  wrap.appendChild(cookieTable(loose, T.cookiesIn + ' (' + loose.length + ')'));
1814
1994
  }
1815
1995
 
@@ -1997,11 +2177,21 @@
1997
2177
  p.appendChild(body);
1998
2178
 
1999
2179
  var foot = el('div', 'ck-panel__foot');
2000
- var save = el('button', 'ck-btn ck-btn--filled', T.save);
2180
+ /* 0.5.11 the panel follows the BANNER's buttons, with no setting of its
2181
+ own: «Сохранить выбор» is the same role as «Принять всё» and wears the
2182
+ accept class, «Принять всё»/«Отклонить всё» here are secondary and wear
2183
+ the settings («Настроить») class. Reusing the role classes means the
2184
+ filled/outline split stays a VALUE change in the tokens — the panel
2185
+ never picks a class from the resolved variant, so a theme.buttons edit
2186
+ is still applyTheme()-only and signature() still need not see buttons.
2187
+ Panel layout is untouched: `.ck-panel__foot .ck-btn{flex:1 1 150px}`
2188
+ (0,2,0) outranks `.ck-btn{flex:1 1 auto}` and the role classes set no
2189
+ flex of their own. */
2190
+ var save = el('button', 'ck-btn ck-btn--accept', T.save);
2001
2191
  save.type = 'button';
2002
- var acc = el('button', 'ck-btn ck-btn--outline', T.acceptAll);
2192
+ var acc = el('button', 'ck-btn ck-btn--settings', T.acceptAll);
2003
2193
  acc.type = 'button';
2004
- var rej = el('button', 'ck-btn ck-btn--outline', T.rejectAll);
2194
+ var rej = el('button', 'ck-btn ck-btn--settings', T.rejectAll);
2005
2195
  rej.type = 'button';
2006
2196
 
2007
2197
  save.addEventListener('click', function () { doSave(); });