@ecomconsult/consentkit 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ck-ui.js CHANGED
@@ -169,21 +169,35 @@
169
169
  /* Palette tokens live in a second, generated stylesheet (see buildThemeCss).
170
170
  They must NOT be inline host styles: an inline value outbeats every :host
171
171
  rule, which would make the dark media query and the forced-mode class dead. */
172
+ /* font-FAMILY only, never the `font` shorthand: theme.font:'inherit' must
173
+ take the host page's typeface without also taking its size, weight and
174
+ line-height, which are what hold the fixed geometry together. */
172
175
  ':host{',
173
- 'font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;',
176
+ 'font-family:var(--ck-font);',
174
177
  'font-size:15px;line-height:1.5;color:var(--ck-ink);',
175
178
  '-webkit-font-smoothing:antialiased}',
176
179
 
177
180
  '.ck-hidden{display:none !important}',
178
181
 
179
182
  'button{font:inherit;color:inherit;margin:0;cursor:pointer}',
180
- 'a{color:var(--ck-accent)}',
183
+ /* --ck-link, not --ck-accent: every accent-coloured TEXT on the card goes
184
+ through the same >= 4.5:1 rule the filled buttons' labels do. A brand
185
+ accent picked to be legible as a BUTTON FILL (white text on it) can be
186
+ far too pale to read as text ON the card — a yellow accent gives a
187
+ perfectly good button and an invisible «Подробнее». --ck-accent stays
188
+ the raw brand colour and is still what paints backgrounds (the switch)
189
+ and the focus ring, which are non-text and answer to 3:1, not 4.5. */
190
+ 'a{color:var(--ck-link)}',
191
+ /* «Подробнее» as a settings-opening button must read as the link it
192
+ replaces, not as a third action button. */
193
+ '.ck-linkbtn{background:none;border:0;padding:0;font:inherit;font-size:inherit;',
194
+ 'color:var(--ck-link);text-decoration:underline;cursor:pointer}',
181
195
  ':focus-visible{outline:2px solid var(--ck-accent);outline-offset:2px;border-radius:4px}',
182
196
 
183
197
  /* ---- banner ---- */
184
198
  '.ck-scrim{position:fixed;inset:0;background:rgba(16,20,30,.28);z-index:2147483000;pointer-events:none}',
185
199
  '.ck-banner{position:fixed;z-index:2147483001;background:var(--ck-bg);color:var(--ck-ink);',
186
- 'border:1px solid var(--ck-line);border-radius:var(--ck-radius);pointer-events:auto}',
200
+ 'border:1px solid var(--ck-line);border-radius:var(--ck-radius-card);pointer-events:auto}',
187
201
  '.ck-banner--bar{left:16px;right:16px;padding:18px 20px;',
188
202
  'display:flex;gap:20px;align-items:center;flex-wrap:wrap}',
189
203
  '.ck-banner--bar.ck-pos-bottom{bottom:16px}',
@@ -191,22 +205,29 @@
191
205
  '.ck-banner--modal{top:50%;left:50%;transform:translate(-50%,-50%);',
192
206
  'width:min(560px,calc(100vw - 32px));max-height:calc(100vh - 32px);overflow:auto;padding:24px}',
193
207
 
194
- /* box: compact card, corner-anchored, no scrim */
195
- '.ck-banner--box{width:min(360px,calc(100vw - 32px));max-height:calc(100vh - 32px);',
196
- 'overflow:auto;padding:20px;display:block}',
208
+ /* box: the SPEC V1.6 reference card — max 540 wide, 24px padding, corner
209
+ anchored, no scrim. */
210
+ '.ck-banner--box{width:min(540px,calc(100vw - 32px));max-height:calc(100vh - 32px);',
211
+ 'overflow:auto;padding:24px;display:block}',
197
212
  '.ck-banner--box.ck-pos-bottom-right{bottom:16px;right:16px}',
198
213
  '.ck-banner--box.ck-pos-bottom-left{bottom:16px;left:16px}',
199
214
  /* Vertical layouts: the copy ends with the "learn more" link, so the gap
200
215
  below it has to clear a text baseline, not just a block edge — 16px
201
216
  reads as attached to the buttons. */
202
217
  '.ck-banner.ck-banner--box p,.ck-banner.ck-banner--modal p{margin-bottom:22px}',
203
- /* both filled buttons share one equal row; outline spans the width below */
204
- '.ck-banner--box .ck-actions{display:grid;grid-template-columns:1fr 1fr;gap:8px}',
205
- '.ck-banner--box .ck-btn{min-width:0;width:100%}',
206
- '.ck-banner--box .ck-btn--outline{grid-column:1 / -1}',
218
+ /* Reference row: three equal buttons, 8px gap, wrapping to a column under
219
+ 560px (the media query below). Keyed off the ROLE classes, never the
220
+ variant ones — since 0.5.0 `settings` may itself be filled. */
221
+ '.ck-banner--box .ck-actions{display:flex;gap:8px;flex-wrap:wrap}',
222
+ '.ck-banner--box .ck-btn{min-width:0;flex:1 1 0}',
207
223
  '.ck-banner__body{flex:1 1 320px;min-width:0}',
208
224
  '.ck-banner h2{margin:0 0 6px;font-size:17px;font-weight:600;letter-spacing:-.01em}',
209
225
  '.ck-banner p{margin:0;color:var(--ck-muted);font-size:14px}',
226
+ /* Box overrides sit AFTER the generic .ck-banner rules on purpose: the two
227
+ selectors have equal specificity, so source order is what decides, and
228
+ placing these with the rest of the box block silently lost. The SPEC
229
+ V1.6 reference calls for a 20/700 heading here. */
230
+ '.ck-banner--box h2{font-size:20px;font-weight:700;margin:0 0 8px}',
210
231
  '.ck-banner--modal p{margin-bottom:20px}',
211
232
  '.ck-banner__more{white-space:nowrap}',
212
233
 
@@ -215,9 +236,27 @@
215
236
  '.ck-banner--modal .ck-actions{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr))}',
216
237
  '.ck-btn{display:inline-flex;align-items:center;justify-content:center;text-align:center;',
217
238
  'min-width:150px;min-height:44px;padding:11px 18px;font-size:14px;font-weight:600;line-height:1.2;',
218
- 'border-radius:var(--ck-radius);border:1px solid transparent;background:transparent;flex:1 1 auto}',
239
+ 'border-radius:var(--ck-radius-btn);border:1px solid transparent;background:transparent;flex:1 1 auto}',
240
+ /* Role classes read their own resolved tokens, so filled/outline is a VALUE
241
+ change, not a class swap — a theme.buttons edit restyles in place and
242
+ needs no remount. box-sizing:border-box is global, so borderWidth:2 does
243
+ not change the outer size: the equal-buttons invariant survives it. */
244
+ '.ck-btn--accept{background:var(--ck-accept-bg);color:var(--ck-accept-fg);',
245
+ 'border-color:var(--ck-accept-bd);border-width:var(--ck-accept-bw)}',
246
+ '.ck-btn--reject{background:var(--ck-reject-bg);color:var(--ck-reject-fg);',
247
+ 'border-color:var(--ck-reject-bd);border-width:var(--ck-reject-bw)}',
248
+ '.ck-btn--settings{background:var(--ck-settings-bg);color:var(--ck-settings-fg);',
249
+ 'border-color:var(--ck-settings-bd);border-width:var(--ck-settings-bw)}',
250
+ /* Kept for the preferences panel's own footer, which is not themable. */
219
251
  '.ck-btn--filled{background:var(--ck-accent);border-color:var(--ck-accent);color:var(--ck-on-accent)}',
220
- '.ck-btn--outline{background:transparent;border-color:var(--ck-accent);color:var(--ck-accent)}',
252
+ /* Live, not a fallback: these paint the preferences panel's own Accept
253
+ all / Reject all. Transparent fill means the LABEL sits on the card, so
254
+ it takes --ck-link like every other accent-coloured text; the BORDER is
255
+ non-text and answers to 3:1, so it keeps the raw accent. The footer's
256
+ background is --ck-soft rather than --ck-bg, but soft is a 5% ink mix of
257
+ the card — the ratio difference is well inside the noise, and a second
258
+ link token per background would break «один код — одни числа». */
259
+ '.ck-btn--outline{background:transparent;border-color:var(--ck-accent);color:var(--ck-link)}',
221
260
  '.ck-btn--ghost{min-width:0;border-color:var(--ck-line);color:var(--ck-ink);font-weight:500}',
222
261
 
223
262
  /* ---- panel ---- */
@@ -225,12 +264,12 @@
225
264
  '.ck-panel{position:fixed;z-index:2147483003;top:50%;left:50%;transform:translate(-50%,-50%);',
226
265
  'width:min(620px,calc(100vw - 32px));max-height:calc(100vh - 48px);',
227
266
  'display:flex;flex-direction:column;background:var(--ck-bg);color:var(--ck-ink);',
228
- 'border:1px solid var(--ck-line);border-radius:var(--ck-radius);overflow:hidden}',
267
+ 'border:1px solid var(--ck-line);border-radius:var(--ck-radius-card);overflow:hidden}',
229
268
  '.ck-panel__head{display:flex;align-items:flex-start;gap:16px;padding:22px 24px 14px;',
230
269
  'border-bottom:1px solid var(--ck-line)}',
231
270
  '.ck-panel__head h2{margin:0 0 4px;font-size:18px;font-weight:600;letter-spacing:-.01em}',
232
271
  '.ck-panel__head p{margin:0;font-size:14px;color:var(--ck-muted)}',
233
- '.ck-x{flex:none;width:36px;height:36px;border-radius:var(--ck-radius);border:1px solid var(--ck-line);',
272
+ '.ck-x{flex:none;width:36px;height:36px;border-radius:var(--ck-radius-btn);border:1px solid var(--ck-line);',
234
273
  'background:transparent;display:inline-flex;align-items:center;justify-content:center;color:var(--ck-muted)}',
235
274
  '.ck-panel__body{overflow:auto;padding:6px 24px 10px;-webkit-overflow-scrolling:touch}',
236
275
  '.ck-panel__foot{display:flex;gap:10px;flex-wrap:wrap;padding:16px 24px;',
@@ -258,13 +297,13 @@
258
297
 
259
298
  /* ---- cookie table ---- */
260
299
  '.ck-det{margin-top:12px}',
261
- '.ck-det>summary{cursor:pointer;font-size:13px;color:var(--ck-accent);',
300
+ '.ck-det>summary{cursor:pointer;font-size:13px;color:var(--ck-link);',
262
301
  'list-style:none;display:inline-flex;align-items:center;gap:6px;padding:2px 0}',
263
302
  '.ck-det>summary::-webkit-details-marker{display:none}',
264
303
  '.ck-det>summary::before{content:"";width:0;height:0;border:4px solid transparent;',
265
304
  'border-left-color:currentColor;border-right:0}',
266
305
  '.ck-det[open]>summary::before{transform:rotate(90deg)}',
267
- '.ck-tablewrap{margin-top:8px;overflow-x:auto;border:1px solid var(--ck-line);border-radius:var(--ck-radius)}',
306
+ '.ck-tablewrap{margin-top:8px;overflow-x:auto;border:1px solid var(--ck-line);border-radius:var(--ck-radius-card)}',
268
307
  'table{border-collapse:collapse;width:100%;font-size:13px;min-width:420px}',
269
308
  'th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--ck-line);vertical-align:top}',
270
309
  'thead th{background:var(--ck-soft);font-weight:600;font-size:12px;color:var(--ck-muted);white-space:nowrap}',
@@ -274,7 +313,7 @@
274
313
 
275
314
  /* ---- floating button ---- */
276
315
  '.ck-fab{position:fixed;left:16px;bottom:16px;z-index:2147482999;width:48px;height:48px;',
277
- 'border-radius:50%;border:1px solid var(--ck-line);background:var(--ck-bg);color:var(--ck-accent);',
316
+ 'border-radius:50%;border:1px solid var(--ck-line);background:var(--ck-bg);color:var(--ck-link);',
278
317
  'display:inline-flex;align-items:center;justify-content:center;padding:0}',
279
318
  '.ck-fab svg{width:24px;height:24px;display:block}',
280
319
 
@@ -285,7 +324,10 @@
285
324
  '.ck-banner--bar{flex-direction:column;align-items:stretch}',
286
325
  '.ck-banner--bar .ck-banner__body{display:contents}',
287
326
  '.ck-banner--bar .ck-banner__body>*{order:1}',
288
- '.ck-banner--bar .ck-actions{order:2}}',
327
+ '.ck-banner--bar .ck-actions{order:2}',
328
+ /* Reference: the box's button row becomes a column under 560px. */
329
+ '.ck-banner--box .ck-actions{flex-direction:column}',
330
+ '.ck-banner--box .ck-btn{flex:1 1 auto;width:100%}}',
289
331
 
290
332
  '@media (prefers-reduced-motion: no-preference){',
291
333
  '.ck-btn,.ck-x,.ck-fab,.ck-switch,.ck-switch::after{transition:background-color .16s ease,border-color .16s ease,color .16s ease,left .16s ease,right .16s ease}}'
@@ -419,20 +461,138 @@
419
461
  /* ----------------------------------------------------------------- theme */
420
462
 
421
463
  // Built-in palettes. Dark values are picked for >= 4.5:1 text contrast.
422
- var LIGHT_RADIUS = '10px';
464
+ // Card backgrounds follow SPEC V1.6 §1: light #ffffff, dark #1c1c1e. The dark
465
+ // card is a NEUTRAL grey, so line/soft/muted below are neutral too — the old
466
+ // blue-tinted #333C4F/#232B3A were derived from the old blue-tinted #1A202D
467
+ // and would read as a colour cast against #1c1c1e.
468
+ var DEFAULT_RADIUS = { card: 16, button: 8 };
469
+ var RADIUS_MIN = 0, RADIUS_MAX = 32;
470
+ var SYSTEM_FONT = 'system-ui,-apple-system,"Segoe UI",Roboto,sans-serif';
471
+
423
472
  var LIGHT = {
424
- bg: '#fff', ink: '#1B2437', accent: '#2B50D8', onAccent: '#fff',
473
+ bg: '#ffffff', ink: '#1B2437', accent: '#2B50D8', onAccent: '#ffffff',
425
474
  muted: '#5b6478', line: '#dfe3ea', soft: '#f4f6f9'
426
475
  };
427
476
  var DARK = {
428
- bg: '#1A202D', ink: '#E6EAF4', accent: '#7B96F0', onAccent: '#12182A',
429
- muted: '#A6B0C6', line: '#333C4F', soft: '#232B3A'
477
+ bg: '#1c1c1e', ink: '#E9E9EB', accent: '#7B96F0', onAccent: '#12182A',
478
+ muted: '#A0A0A8', line: '#3A3A3C', soft: '#2C2C2E'
430
479
  };
431
480
 
432
481
  function str(v) {
433
482
  return (typeof v === 'string' && v.trim()) ? v.trim() : null;
434
483
  }
435
484
 
485
+ /* ------------------------------------------------------------- contrast */
486
+
487
+ /* WCAG 2.1 relative luminance and contrast ratio. Pure, hex-only: everything
488
+ that reaches them has already passed sanitizeCssValue(), and a value that
489
+ is not a hex triple (a colour name, an rgb() string, a color-mix()) simply
490
+ cannot be measured here — those return null and every caller then leaves
491
+ the colour exactly as the author wrote it rather than guessing. Silently
492
+ "fixing" a colour we cannot read would be worse than not fixing it. */
493
+
494
+ // '#abc' | '#aabbcc' | '#aabbccdd' -> [r,g,b] 0..255, or null.
495
+ function parseHex(hex) {
496
+ var s = str(hex);
497
+ if (!s || s.charAt(0) !== '#') return null;
498
+ s = s.slice(1);
499
+ if (s.length === 3 || s.length === 4) {
500
+ s = s.charAt(0) + s.charAt(0) + s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2);
501
+ } else if (s.length === 6 || s.length === 8) {
502
+ s = s.slice(0, 6);
503
+ } else {
504
+ return null;
505
+ }
506
+ if (!/^[0-9a-fA-F]{6}$/.test(s)) return null;
507
+ return [
508
+ parseInt(s.slice(0, 2), 16),
509
+ parseInt(s.slice(2, 4), 16),
510
+ parseInt(s.slice(4, 6), 16)
511
+ ];
512
+ }
513
+
514
+ function toHex(rgb) {
515
+ var out = '#';
516
+ for (var i = 0; i < 3; i++) {
517
+ var v = Math.round(rgb[i]);
518
+ if (v < 0) v = 0; else if (v > 255) v = 255;
519
+ out += (v + 0x100).toString(16).slice(1);
520
+ }
521
+ return out;
522
+ }
523
+
524
+ // WCAG 2.1: L = 0.2126R + 0.7152G + 0.0722B over linearised channels.
525
+ function relativeLuminance(hex) {
526
+ var rgb = parseHex(hex);
527
+ if (!rgb) return null;
528
+ var lin = [];
529
+ for (var i = 0; i < 3; i++) {
530
+ var c = rgb[i] / 255;
531
+ lin.push(c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
532
+ }
533
+ return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
534
+ }
535
+
536
+ // (L1 + 0.05) / (L2 + 0.05), lighter first. null when either side is unreadable.
537
+ function contrastRatio(a, b) {
538
+ var la = relativeLuminance(a);
539
+ var lb = relativeLuminance(b);
540
+ if (la === null || lb === null) return null;
541
+ var hi = la > lb ? la : lb;
542
+ var lo = la > lb ? lb : la;
543
+ return (hi + 0.05) / (lo + 0.05);
544
+ }
545
+
546
+ var WHITE = '#ffffff';
547
+ var NEAR_BLACK = '#161616'; // SPEC §1: white or #161616, whichever contrasts more
548
+
549
+ /* SPEC §1, the TEXT rule: keep the author's fg when it clears `min` against
550
+ bg; otherwise swap to white or #161616 — whichever wins. Unreadable input
551
+ (a colour name, rgb(), a missing value) is returned untouched with
552
+ adjusted:false: the author sees their own colour, not a silent rewrite. */
553
+ function ensureContrast(fg, bg, min) {
554
+ var floor = typeof min === 'number' && min > 0 ? min : 4.5;
555
+ var have = contrastRatio(fg, bg);
556
+ if (have === null) return { color: fg, adjusted: false, ratio: have };
557
+ if (have >= floor) return { color: fg, adjusted: false, ratio: have };
558
+ var rw = contrastRatio(WHITE, bg);
559
+ var rb = contrastRatio(NEAR_BLACK, bg);
560
+ var pick = (rw !== null && rb !== null && rw >= rb) ? WHITE : NEAR_BLACK;
561
+ return { color: pick, adjusted: true, ratio: contrastRatio(pick, bg) };
562
+ }
563
+
564
+ /* SPEC §1, the BORDER rule: an outline button's border must clear >= 3:1
565
+ against the CARD background, so the button is visible as a button at all.
566
+ Stepwise rather than a jump to black/white: a brand colour that is only
567
+ slightly too pale should stay recognisably the brand colour. Direction is
568
+ chosen by which way there is room to move — away from the card. */
569
+ function stepToContrast(color, bg, min) {
570
+ var floor = typeof min === 'number' && min > 0 ? min : 3;
571
+ var have = contrastRatio(color, bg);
572
+ if (have === null) return { color: color, adjusted: false, ratio: have };
573
+ if (have >= floor) return { color: color, adjusted: false, ratio: have };
574
+
575
+ var rgb = parseHex(color);
576
+ var bgLum = relativeLuminance(bg);
577
+ // Light card -> darken the border; dark card -> lighten it.
578
+ var darken = bgLum > 0.5;
579
+ var cur = [rgb[0], rgb[1], rgb[2]];
580
+ // 5% steps of the remaining range; 40 steps always reaches pure black/white.
581
+ for (var i = 0; i < 40; i++) {
582
+ for (var c = 0; c < 3; c++) {
583
+ cur[c] = darken ? cur[c] - (cur[c] * 0.06) - 2 : cur[c] + ((255 - cur[c]) * 0.06) + 2;
584
+ if (cur[c] < 0) cur[c] = 0; else if (cur[c] > 255) cur[c] = 255;
585
+ }
586
+ var next = toHex(cur);
587
+ var r = contrastRatio(next, bg);
588
+ if (r !== null && r >= floor) return { color: next, adjusted: true, ratio: r };
589
+ }
590
+ var last = toHex(cur);
591
+ return { color: last, adjusted: true, ratio: contrastRatio(last, bg) };
592
+ }
593
+
594
+ /* ------------------------------------------------------- config grammar */
595
+
436
596
  /* Config values are interpolated into the TEXT of a generated stylesheet, so
437
597
  an unvalidated value can close the declaration and open rules of its own
438
598
  ("10px;}.ck-btn--filled{display:none" hides "Reject all"). config.theme is
@@ -443,10 +603,11 @@
443
603
  fit — a broken colour is a cosmetic bug, an injected rule is a defacement
444
604
  and can strip the reject button, which is a consent-validity problem.
445
605
 
446
- Sanitising happens at the entry points in buildThemeCss (the eight
447
- config reads), not in tokenBlock(): by the time values reach derive() and
448
- color-mix() they are already clean, and the built-in constants and
449
- generated color-mix() strings must not be re-validated by this grammar. */
606
+ Sanitising happens at the entry points (the config reads in
607
+ resolveButtonStyles and buildThemeCss), not in tokenBlock(): by the time
608
+ values reach derive() and color-mix() they are already clean, and the
609
+ built-in constants and generated color-mix()/hex strings must not be
610
+ re-validated by this grammar. */
450
611
  var RE_CSS = {
451
612
  // #RGB / #RRGGBB / #RRGGBBAA (and #RGBA), rgb()/rgba()/hsl()/hsla() with
452
613
  // numbers, commas, spaces, %, decimals and slashes, or a bare colour name.
@@ -465,8 +626,179 @@
465
626
  return v;
466
627
  }
467
628
 
629
+ // theme.radius: {card,button} in px, clamped 0..32. A bare string or number
630
+ // is the pre-0.5.0 form and still works: it sets the CARD radius, the button
631
+ // keeps its own default. Anything unreadable falls back to the default.
632
+ function clampRadius(v, fallback) {
633
+ var n;
634
+ if (typeof v === 'number') n = v;
635
+ else if (typeof v === 'string' && /^\d+(\.\d+)?(px)?$/.test(v.trim())) n = parseFloat(v);
636
+ else return fallback;
637
+ if (!isFinite(n)) return fallback;
638
+ if (n < RADIUS_MIN) n = RADIUS_MIN;
639
+ if (n > RADIUS_MAX) n = RADIUS_MAX;
640
+ return Math.round(n);
641
+ }
642
+
643
+ function resolveRadius(theme) {
644
+ var r = theme && theme.radius;
645
+ if (r && typeof r === 'object') {
646
+ return {
647
+ card: clampRadius(r.card, DEFAULT_RADIUS.card),
648
+ button: clampRadius(r.button, DEFAULT_RADIUS.button)
649
+ };
650
+ }
651
+ // Legacy scalar ('10px'): card only.
652
+ if (typeof r === 'string' || typeof r === 'number') {
653
+ return { card: clampRadius(r, DEFAULT_RADIUS.card), button: DEFAULT_RADIUS.button };
654
+ }
655
+ return { card: DEFAULT_RADIUS.card, button: DEFAULT_RADIUS.button };
656
+ }
657
+
658
+ // theme.font: 'system' keeps the pre-0.5.0 stack; anything else (including the
659
+ // default) inherits the host page's family. Sizes stay explicit either way —
660
+ // this is font-FAMILY, deliberately not the `font` shorthand, which would drag
661
+ // in the page's size, weight and line-height and break the fixed geometry.
662
+ function resolveFont(theme) {
663
+ return (theme && theme.font === 'system') ? SYSTEM_FONT : 'inherit';
664
+ }
665
+
666
+ /* --------------------------------------------------- button resolution */
667
+
668
+ var BTN_ROLES = ['accept', 'reject', 'settings'];
669
+ var DEFAULT_VARIANT = { accept: 'filled', reject: 'filled', settings: 'outline' };
670
+
671
+ var warnedVariant = false;
672
+ // Debug-only, once per page: a config error worth surfacing to whoever is
673
+ // looking, but never console noise on a visitor's production page. Checked
674
+ // lazily because ck-debug.js loads AFTER this file and flips `active` later.
675
+ function warnVariantMismatch() {
676
+ if (warnedVariant) return;
677
+ var dbg = (typeof window !== 'undefined') && window.__ckDebug;
678
+ if (!dbg || dbg.active !== true) return;
679
+ warnedVariant = true;
680
+ try {
681
+ console.warn('[ConsentKit] theme.buttons.accept.variant and .reject.variant differ. ' +
682
+ 'Accept and reject must look identical (equal-buttons invariant, SPEC §1) — ' +
683
+ 'accept\'s variant is used for both.');
684
+ } catch (e) { /* noop */ }
685
+ }
686
+
687
+ /* Resolve the three buttons for ONE mode into concrete, contrast-checked
688
+ colours. Pure: takes the theme object and 'light'|'dark', returns plain
689
+ data. This is the single source of truth the generated stylesheet, the
690
+ debug panel and (per SPEC §3) the cabinet all read — "один код — одни
691
+ числа": nothing recomputes these numbers a second time.
692
+
693
+ `palette` is optional and lets buildThemeCss pass the ALREADY-RESOLVED
694
+ accent/card colours for the mode, so a theme.dark.accent is honoured. */
695
+ function resolveButtonStyles(theme, mode, palette) {
696
+ var t = (theme && typeof theme === 'object') ? theme : {};
697
+ var base = mode === 'dark' ? DARK : LIGHT;
698
+ var pal = (palette && typeof palette === 'object') ? palette : {};
699
+
700
+ var cardBg = sanitizeCssValue('color', pal.bg, base.bg);
701
+ var accent = sanitizeCssValue('color', pal.accent, base.accent);
702
+ // theme.dark.onAccent stays meaningful: it is the GIVEN fg for filled
703
+ // buttons in that mode. The >= 4.5 rule may still override it.
704
+ var onAccent = sanitizeCssValue('color', pal.onAccent, base.onAccent);
705
+
706
+ var cfg = (t.buttons && typeof t.buttons === 'object') ? t.buttons : {};
707
+
708
+ // Equal-buttons invariant (SPEC §1): accept and reject share size and
709
+ // weight, so they must share the variant too. When they disagree, accept
710
+ // wins — silently for a visitor, loudly in debug mode.
711
+ function variantOf(role) {
712
+ var c = cfg[role];
713
+ var v = c && c.variant;
714
+ return (v === 'filled' || v === 'outline') ? v : DEFAULT_VARIANT[role];
715
+ }
716
+ var acceptVariant = variantOf('accept');
717
+ var rejectVariant = variantOf('reject');
718
+ if (acceptVariant !== rejectVariant) warnVariantMismatch();
719
+
720
+ var variants = {
721
+ accept: acceptVariant,
722
+ reject: acceptVariant, // invariant: always accept's
723
+ settings: variantOf('settings')
724
+ };
725
+
726
+ /* The accent as TEXT on the card. Same >= 4.5 rule, same function, one
727
+ number: links, «Подробнее», the cookie-table summary and the floating
728
+ button's icon all read the token this produces, so none of them can
729
+ drift from what the debug panel and the cabinet quote. Note the
730
+ measurement is against cardBg — an accent that is fine inside a filled
731
+ button can still be unreadable on the card behind it. */
732
+ var linkRes = ensureContrast(accent, cardBg, 4.5);
733
+
734
+ var out = {
735
+ mode: mode,
736
+ cardBg: cardBg,
737
+ accent: accent,
738
+ link: {
739
+ color: linkRes.color,
740
+ ratio: linkRes.ratio,
741
+ adjusted: linkRes.adjusted,
742
+ against: cardBg
743
+ },
744
+ buttons: {}
745
+ };
746
+
747
+ for (var i = 0; i < BTN_ROLES.length; i++) {
748
+ var role = BTN_ROLES[i];
749
+ var c = (cfg[role] && typeof cfg[role] === 'object') ? cfg[role] : {};
750
+ var variant = variants[role];
751
+
752
+ var bw = (c.borderWidth === 2 || c.borderWidth === '2') ? 2 : 1;
753
+ var rec;
754
+
755
+ if (variant === 'filled') {
756
+ // bg: author's, else the accent. fg: author's, else onAccent for the
757
+ // default accent fill, then checked against the resolved bg.
758
+ var bg = sanitizeCssValue('color', c.bg, accent);
759
+ var wantFg = sanitizeCssValue('color', c.fg, onAccent);
760
+ var fgRes = ensureContrast(wantFg, bg, 4.5);
761
+ var bd = sanitizeCssValue('color', c.border, bg);
762
+ rec = {
763
+ variant: 'filled',
764
+ bg: bg,
765
+ fg: fgRes.color,
766
+ border: bd,
767
+ borderWidth: bw,
768
+ ratio: fgRes.ratio,
769
+ adjusted: fgRes.adjusted,
770
+ against: bg
771
+ };
772
+ } else {
773
+ // outline: transparent fill, so everything is measured against the CARD.
774
+ // border >= 3:1 with the card (stepped), text = the border colour with
775
+ // the >= 4.5 rule against the card.
776
+ var wantBd = sanitizeCssValue('color', c.border, accent);
777
+ var bdRes = stepToContrast(wantBd, cardBg, 3);
778
+ var wantTx = str(c.fg) ? sanitizeCssValue('color', c.fg, bdRes.color) : bdRes.color;
779
+ var txRes = ensureContrast(wantTx, cardBg, 4.5);
780
+ rec = {
781
+ variant: 'outline',
782
+ bg: sanitizeCssValue('color', c.bg, 'transparent'),
783
+ fg: txRes.color,
784
+ border: bdRes.color,
785
+ borderWidth: bw,
786
+ ratio: txRes.ratio,
787
+ adjusted: txRes.adjusted || bdRes.adjusted,
788
+ borderRatio: bdRes.ratio,
789
+ borderAdjusted: bdRes.adjusted,
790
+ against: cardBg
791
+ };
792
+ }
793
+ out.buttons[role] = rec;
794
+ }
795
+ return out;
796
+ }
797
+
798
+ /* ------------------------------------------------------------- stylesheet */
799
+
468
800
  // One :host{} block of custom properties for a resolved palette.
469
- function tokenBlock(sel, p, radius) {
801
+ function tokenBlock(sel, p, extra) {
470
802
  var d = [
471
803
  '--ck-bg:' + p.bg,
472
804
  '--ck-ink:' + p.ink,
@@ -476,7 +808,28 @@
476
808
  '--ck-line:' + p.line,
477
809
  '--ck-soft:' + p.soft
478
810
  ];
479
- if (radius) d.push('--ck-radius:' + radius);
811
+ // Per-button tokens: the ROLE classes in the static sheet read these, so a
812
+ // filled/outline change is a value change and never a class swap. That is
813
+ // what keeps a theme.buttons edit on the applyTheme()-only path — no
814
+ // remount, and signature() need not know about buttons at all.
815
+ // Resolved by resolveButtonStyles alongside the buttons, threaded in the
816
+ // same way as __buttons: one pass per mode, so the light and dark blocks
817
+ // each carry their own contrast-checked link colour.
818
+ if (p.__link) d.push('--ck-link:' + p.__link.color);
819
+ var btns = p.__buttons;
820
+ if (btns) {
821
+ for (var i = 0; i < BTN_ROLES.length; i++) {
822
+ var role = BTN_ROLES[i];
823
+ var b = btns[role];
824
+ d.push('--ck-' + role + '-bg:' + b.bg);
825
+ d.push('--ck-' + role + '-fg:' + b.fg);
826
+ d.push('--ck-' + role + '-bd:' + b.border);
827
+ d.push('--ck-' + role + '-bw:' + b.borderWidth + 'px');
828
+ }
829
+ }
830
+ if (extra) {
831
+ for (var k = 0; k < extra.length; k++) d.push(extra[k]);
832
+ }
480
833
  return sel + '{' + d.join(';') + '}';
481
834
  }
482
835
 
@@ -498,11 +851,9 @@
498
851
  function buildThemeCss(cfg) {
499
852
  var theme = (cfg && cfg.theme) || {};
500
853
  var dk = (theme.dark && typeof theme.dark === 'object') ? theme.dark : {};
501
- // Every value below is interpolated into stylesheet text — see
502
- // sanitizeCssValue(). A rejected value falls back to the token default, so
503
- // derive()'s "bg !== base.bg" check collapses to exactly the built-in
504
- // palette rather than a half-substituted one.
505
- var radius = sanitizeCssValue('length', theme.radius, LIGHT_RADIUS);
854
+
855
+ var radius = resolveRadius(theme);
856
+ var font = resolveFont(theme);
506
857
 
507
858
  // Light: config overrides on top of the built-in light palette.
508
859
  var light = derive(LIGHT,
@@ -512,17 +863,37 @@
512
863
 
513
864
  // Dark: theme.dark overrides on top of the built-in dark palette.
514
865
  // A light-only theme.accent deliberately does NOT carry into dark — the
515
- // default #2B50D8 on #1A202D is ~2.5:1 and would fail AA.
866
+ // default #2B50D8 on #1c1c1e is ~2.4:1 and would fail AA.
516
867
  var dark = derive(DARK,
517
868
  sanitizeCssValue('color', dk.bg, DARK.bg),
518
869
  sanitizeCssValue('color', dk.ink, DARK.ink));
519
870
  if (str(dk.accent)) dark.accent = sanitizeCssValue('color', dk.accent, DARK.accent);
520
871
  if (str(dk.onAccent)) dark.onAccent = sanitizeCssValue('color', dk.onAccent, DARK.onAccent);
521
872
 
873
+ // Buttons are resolved per mode against that mode's real card colour, so a
874
+ // custom dark card changes the outline border the same way a custom light
875
+ // one does.
876
+ var lightBtn = resolveButtonStyles(theme, 'light', light);
877
+ var darkBtn = resolveButtonStyles(theme, 'dark', dark);
878
+ light.__buttons = lightBtn.buttons;
879
+ dark.__buttons = darkBtn.buttons;
880
+ light.__link = lightBtn.link;
881
+ dark.__link = darkBtn.link;
882
+
522
883
  var mode = theme.mode;
523
884
  if (mode !== 'light' && mode !== 'dark') mode = 'auto';
524
885
 
525
- var out = [tokenBlock(':host', light, radius)];
886
+ // Geometry and family are mode-independent, so they ride on the base block.
887
+ var rootExtra = [
888
+ '--ck-radius-card:' + radius.card + 'px',
889
+ '--ck-radius-btn:' + radius.button + 'px',
890
+ // Kept as an alias so any integrator CSS (and older builds' rules) that
891
+ // reads --ck-radius still lands on the card radius.
892
+ '--ck-radius:' + radius.card + 'px',
893
+ '--ck-font:' + font
894
+ ];
895
+
896
+ var out = [tokenBlock(':host', light, rootExtra)];
526
897
  if (mode === 'auto') {
527
898
  // forced-light class must still beat a dark system preference
528
899
  out.push('@media (prefers-color-scheme: dark){' +
@@ -541,7 +912,8 @@
541
912
  ':host(.ck-mode-dark){--ck-muted:' + DARK.muted + ';--ck-line:' + DARK.line +
542
913
  ';--ck-soft:' + DARK.soft + '}}');
543
914
 
544
- return { css: out.join('\n'), mode: mode };
915
+ return { css: out.join('\n'), mode: mode, radius: radius, font: font,
916
+ light: lightBtn, dark: darkBtn };
545
917
  }
546
918
 
547
919
  function applyTheme(cfg) {
@@ -642,6 +1014,33 @@
642
1014
  return wrap;
643
1015
  }
644
1016
 
1017
+ /* SPEC V1.6 §2 — what «Подробнее» does.
1018
+
1019
+ Before 0.5.0 the link was rendered with href="#" and NO click handler: it
1020
+ did nothing but jump to the top of the page. There are now three honest
1021
+ outcomes, and the default depends on whether a policy address exists —
1022
+ 'settings' without one, 'policy' with one, so an integrator who only
1023
+ supplies a URL gets the link they obviously meant. This is why
1024
+ detailsAction must NOT carry a default in the core's DEFAULT_CONFIG: a
1025
+ merged concrete value would make the URL-sensitive default unreachable.
1026
+
1027
+ 'policy' with no usable URL degrades to 'settings' rather than rendering a
1028
+ dead link. Only http(s) is accepted — javascript: and data: URLs in a
1029
+ link the visitor is invited to click are an XSS vector, and a relative
1030
+ path cannot be validated here without a base. */
1031
+ function resolveDetails(cfg) {
1032
+ var texts = (cfg && cfg.texts && typeof cfg.texts === 'object') ? cfg.texts : {};
1033
+ var url = str(texts.policyUrl);
1034
+ if (url && !/^https?:\/\//i.test(url)) url = null;
1035
+
1036
+ var action = texts.detailsAction;
1037
+ if (action !== 'policy' && action !== 'settings' && action !== 'hide') {
1038
+ action = url ? 'policy' : 'settings';
1039
+ }
1040
+ if (action === 'policy' && !url) action = 'settings';
1041
+ return { kind: action, href: action === 'policy' ? url : null };
1042
+ }
1043
+
645
1044
  // Unknown type -> bar/bottom. Known type with an unrecognized position ->
646
1045
  // that type's own default (bar: bottom, box: bottom-left — the side away
647
1046
  // from the chat widgets and scroll-to-top buttons most sites put on the right).
@@ -676,10 +1075,26 @@
676
1075
  body.appendChild(h);
677
1076
 
678
1077
  var p = el('p');
679
- p.appendChild(document.createTextNode(T.bannerText + ' '));
680
- var link = el('a', 'ck-banner__more', T.more);
681
- link.href = '#';
682
- p.appendChild(link);
1078
+ var det = resolveDetails(cfg);
1079
+ if (det.kind === 'hide') {
1080
+ p.appendChild(document.createTextNode(T.bannerText));
1081
+ } else {
1082
+ p.appendChild(document.createTextNode(T.bannerText + ' '));
1083
+ if (det.kind === 'policy') {
1084
+ var link = el('a', 'ck-banner__more', T.more);
1085
+ link.href = det.href;
1086
+ link.target = '_blank';
1087
+ link.rel = 'noopener'; // never hand the policy page window.opener
1088
+ p.appendChild(link);
1089
+ } else {
1090
+ // A control that changes what is on screen is a button, not a link:
1091
+ // screen readers announce it correctly and it needs no href to fake.
1092
+ var more = el('button', 'ck-banner__more ck-linkbtn', T.more);
1093
+ more.type = 'button';
1094
+ more.addEventListener('click', function () { openPanel(more); });
1095
+ p.appendChild(more);
1096
+ }
1097
+ }
683
1098
  body.appendChild(p);
684
1099
 
685
1100
  var pb = buildPoweredBy(cfg);
@@ -701,11 +1116,11 @@
701
1116
  b.appendChild(body);
702
1117
 
703
1118
  var actions = el('div', 'ck-actions');
704
- var accept = el('button', 'ck-btn ck-btn--filled', T.acceptAll);
1119
+ var accept = el('button', 'ck-btn ck-btn--accept', T.acceptAll);
705
1120
  accept.type = 'button';
706
- var reject = el('button', 'ck-btn ck-btn--filled', T.rejectAll);
1121
+ var reject = el('button', 'ck-btn ck-btn--reject', T.rejectAll);
707
1122
  reject.type = 'button';
708
- var custom = el('button', 'ck-btn ck-btn--outline', T.customize);
1123
+ var custom = el('button', 'ck-btn ck-btn--settings', T.customize);
709
1124
  custom.type = 'button';
710
1125
 
711
1126
  accept.addEventListener('click', function () { doAcceptAll(); });
@@ -940,6 +1355,10 @@
940
1355
  String(c.language || 'auto'),
941
1356
  lay.type, String(lay.position),
942
1357
  Array.isArray(table) ? table.length : 0,
1358
+ // Structural: link / button / nothing are three different DOM shapes,
1359
+ // and mount() is one-shot. Buttons and colours are deliberately NOT
1360
+ // here — they are token values and restyle in place.
1361
+ resolveDetails(c).kind,
943
1362
  brandSignature(c)
944
1363
  ].join('|');
945
1364
  }
@@ -996,6 +1415,33 @@
996
1415
 
997
1416
  /* ---------------------------------------------------------------- events */
998
1417
 
1418
+ /* ------------------------------------------------------- pure exports */
1419
+
1420
+ /* SPEC V1.6 §1: the cabinet's theme editor must show the same numbers the
1421
+ banner actually paints — «один код — одни числа». Published BEFORE the
1422
+ SSR guard below on purpose: these are pure functions with no DOM in them,
1423
+ so they are reachable (and testable in node) on a page or in a process
1424
+ that never renders anything.
1425
+
1426
+ Not a stable public API — the underscore says so — but the theme editor
1427
+ and the debug panel are both expected to read it rather than reimplement
1428
+ the arithmetic and drift. */
1429
+ (function () {
1430
+ var ck = api();
1431
+ if (!ck) return;
1432
+ ck._contrast = {
1433
+ relativeLuminance: relativeLuminance,
1434
+ contrastRatio: contrastRatio,
1435
+ ensureContrast: ensureContrast,
1436
+ stepToContrast: stepToContrast,
1437
+ resolveButtonStyles: resolveButtonStyles,
1438
+ resolveRadius: resolveRadius,
1439
+ resolveFont: resolveFont,
1440
+ resolveDetails: resolveDetails,
1441
+ buildThemeCss: buildThemeCss
1442
+ };
1443
+ })();
1444
+
999
1445
  // SSR-safe: with no DOM there is nothing to render or listen to, so importing
1000
1446
  // this file in Node is a no-op rather than a throw (mirrors the core).
1001
1447
  if (typeof document === 'undefined') return;