@ecomconsult/consentkit 0.3.5 → 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,19 +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
- /* min-width:0 so a wide logo shrinks rather than shoving the close button
232
- off. Scoped to :has(.ck-brand) — applying it unconditionally changes the
233
- header block's flex sizing (480px -> 518px) on unbranded panels too, which
234
- would break byte-for-byte backward compatibility. Browsers without :has()
235
- simply keep today's sizing; the logo is width-capped at 160px regardless,
236
- so the close button still has room. */
237
- '.ck-panel__head>div:first-child:has(.ck-brand){flex:1 1 auto;min-width:0}',
238
270
  '.ck-panel__head h2{margin:0 0 4px;font-size:18px;font-weight:600;letter-spacing:-.01em}',
239
271
  '.ck-panel__head p{margin:0;font-size:14px;color:var(--ck-muted)}',
240
- '.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);',
241
273
  'background:transparent;display:inline-flex;align-items:center;justify-content:center;color:var(--ck-muted)}',
242
274
  '.ck-panel__body{overflow:auto;padding:6px 24px 10px;-webkit-overflow-scrolling:touch}',
243
275
  '.ck-panel__foot{display:flex;gap:10px;flex-wrap:wrap;padding:16px 24px;',
@@ -265,13 +297,13 @@
265
297
 
266
298
  /* ---- cookie table ---- */
267
299
  '.ck-det{margin-top:12px}',
268
- '.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);',
269
301
  'list-style:none;display:inline-flex;align-items:center;gap:6px;padding:2px 0}',
270
302
  '.ck-det>summary::-webkit-details-marker{display:none}',
271
303
  '.ck-det>summary::before{content:"";width:0;height:0;border:4px solid transparent;',
272
304
  'border-left-color:currentColor;border-right:0}',
273
305
  '.ck-det[open]>summary::before{transform:rotate(90deg)}',
274
- '.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)}',
275
307
  'table{border-collapse:collapse;width:100%;font-size:13px;min-width:420px}',
276
308
  'th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--ck-line);vertical-align:top}',
277
309
  'thead th{background:var(--ck-soft);font-weight:600;font-size:12px;color:var(--ck-muted);white-space:nowrap}',
@@ -279,62 +311,9 @@
279
311
  'td.ck-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}',
280
312
  '.ck-empty{margin:8px 0 0;font-size:13px;color:var(--ck-muted)}',
281
313
 
282
- /* ---- branding: logo + powered-by ----
283
- The logo sits inline with the title in a flex row. That was chosen over a
284
- separate band above the heading because .ck-banner--bar is a single
285
- vertically-centred flex row: a stacked logo adds a height band there and
286
- nowhere else, so bar/box/modal would drift apart. Inline keeps one rule
287
- for all three layouts and leaves existing margins untouched — .ck-brand
288
- carries the whole gap, h2 keeps its own margin. */
289
- '.ck-brand{display:flex;align-items:center;gap:10px;margin:0 0 8px}',
290
- /* Attribution foot of the banner: logo + credit on one muted line below
291
- the buttons. flex-basis 100% keeps it on its own row in the bar layout,
292
- where the actions sit beside the text.
293
-
294
- The mark is desaturated here rather than shipped as a second grey asset:
295
- an agency logo in full brand colour reads as a second call to action
296
- competing with the consent buttons. grayscale() flattens the hue and the
297
- opacity lifts it off pure black, so it sits at signature weight in both
298
- themes without the integrator preparing anything. */
299
- '.ck-foot{display:flex;align-items:center;gap:8px;flex-wrap:wrap;',
300
- 'margin:14px 0 0}',
301
- '.ck-foot .ck-brand__logo,.ck-foot .ck-brand__logo svg{',
302
- 'filter:grayscale(1);opacity:.55}',
303
- '.ck-foot .ck-brand__link:hover .ck-brand__logo,',
304
- '.ck-foot .ck-brand__link:focus-visible .ck-brand__logo{opacity:.8}',
305
- '.ck-foot .ck-brand{margin:0}',
306
- /* Beats the flex:1 1 100% the standalone .ck-powered carries (it needs a
307
- full row of its own when there is no logo beside it). */
308
- '.ck-foot p.ck-powered,.ck-banner .ck-foot p.ck-powered{margin:0;flex:0 1 auto}',
309
- /* In the panel the foot shares a flex row with the action buttons, so it
310
- claims a row of its own below them. */
311
- '.ck-panel__foot .ck-foot{flex:1 1 100%;margin:2px 0 0}',
312
- '.ck-brand__logo{display:block;width:auto;max-width:160px;height:var(--ck-logo-h,24px);',
313
- 'flex:none;object-fit:contain}',
314
- '.ck-brand__logo svg{display:block;width:auto;height:100%;max-width:160px}',
315
- '.ck-brand a.ck-brand__link{display:inline-flex;align-items:center;text-decoration:none;flex:none}',
316
- /* Dark-variant swap is CSS-driven, mirroring buildThemeCss()'s cascade
317
- exactly (same three selectors, same :not(.ck-mode-light) guard). Reading
318
- the theme in JS would desync in auto mode and would not follow a live
319
- system theme flip. */
320
- '.ck-brand__dark{display:none}',
321
- '.ck-brand__has-dark .ck-brand__light{display:block}',
322
-
323
- /* ---- powered-by ----
324
- Deliberately quiet: muted colour, 12px, normal weight, and it comes after
325
- the action row in DOM order. It must not compete with the consent buttons. */
326
- /* .ck-banner p sets font-size:14px at equal specificity and appears later in
327
- this sheet, so it would win over a bare .ck-powered. Qualifying the
328
- selector keeps the attribution smaller than the button text (14px) without
329
- reaching for !important. */
330
- '.ck-powered,.ck-banner p.ck-powered{margin:12px 0 0;font-size:12px;line-height:1.4;',
331
- 'color:var(--ck-muted);flex:1 1 100%;font-weight:400}',
332
- '.ck-powered a{color:var(--ck-muted);text-decoration:underline}',
333
- '.ck-panel__foot .ck-powered{margin:0;align-self:center}',
334
-
335
314
  /* ---- floating button ---- */
336
315
  '.ck-fab{position:fixed;left:16px;bottom:16px;z-index:2147482999;width:48px;height:48px;',
337
- '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);',
338
317
  'display:inline-flex;align-items:center;justify-content:center;padding:0}',
339
318
  '.ck-fab svg{width:24px;height:24px;display:block}',
340
319
 
@@ -342,15 +321,13 @@
342
321
  '.ck-banner--bar{left:8px;right:8px;bottom:8px;padding:16px}',
343
322
  '.ck-actions{width:100%}.ck-btn{min-width:0;flex:1 1 100%}',
344
323
  '.ck-banner.ck-banner--bar p{margin-bottom:22px}',
345
- /* Narrow bar stacks into a column, so the foot — which lives at the end of
346
- the text block for the wide side-by-side layout — would sit between the
347
- question and the buttons answering it. Lift it out of the text block and
348
- order it last. */
349
324
  '.ck-banner--bar{flex-direction:column;align-items:stretch}',
350
325
  '.ck-banner--bar .ck-banner__body{display:contents}',
351
326
  '.ck-banner--bar .ck-banner__body>*{order:1}',
352
327
  '.ck-banner--bar .ck-actions{order:2}',
353
- '.ck-banner--bar .ck-foot{order:3;margin-top:14px}}',
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%}}',
354
331
 
355
332
  '@media (prefers-reduced-motion: no-preference){',
356
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}}'
@@ -376,350 +353,55 @@
376
353
  return (typeof window !== 'undefined' && window.ConsentKit) || null;
377
354
  }
378
355
 
379
- /* -------------------------------------------------------------- branding */
380
-
381
- /* Restraint is the design rule here, not a matter of taste.
382
-
383
- A consent banner is shown to every visitor of the site that installs it,
384
- and it asks them a legal question. An agency logo, agency colours and an
385
- attribution line all at once make it read as the agency's dialogue rather
386
- than the site's own — visitors trust it less, and the banner competes with
387
- the page it sits on.
388
-
389
- So: everything in `branding` is off unless asked for, and the recommended
390
- shape is one small logo (16–20px) OR one attribution line — with
391
- theme.accent left matching the HOST SITE, never the agency's colour.
392
- Nothing here may outweigh the consent buttons. */
393
-
394
- /* SVG sanitiser.
395
-
396
- branding.logo may be a raw SVG string coming from a server-rendered config
397
- or a WordPress admin field. That is untrusted input, so it never reaches
398
- innerHTML: `<svg onload=...>` executes on insertion, and so do SMIL
399
- `<animate onbegin=...>` and `<foreignObject><img onerror=...>`.
400
-
401
- Approach chosen: parse inert, then REBUILD rather than strip-and-adopt.
402
- DOMParser with 'image/svg+xml' yields a detached, non-live document where
403
- nothing runs. We then walk that tree and construct a brand-new tree with
404
- createElementNS, copying across only allowlisted tags and attributes.
405
-
406
- Rebuilding is what makes this safe rather than merely careful. The rejected
407
- alternative — importNode/appendChild the parsed tree after deleting bad
408
- attributes — arms every inline handler at the moment of adoption, so a
409
- single missed attribute name is live code. Here an attribute we do not
410
- recognise is simply never written, so the failure mode is a missing
411
- decoration, not script execution. Allowlists (closed) beat blocklists
412
- (open-ended) for the same reason.
413
-
414
- Deliberately excluded, each for a concrete reason:
415
- script - obvious
416
- foreignObject - escape hatch back into full HTML
417
- use, image - can reference/fetch external documents
418
- a - javascript: navigation inside the logo
419
- style - CSS escapes, and it would leak out of the
420
- logo into our own shadow-root styling
421
- animate/set/animateTransform - SMIL takes an attributeName and can drive
422
- arbitrary attributes, plus on* timing events
423
-
424
- Anything unexpected bails to null (no logo) rather than partially rendering. */
425
-
426
- var SVG_NS = 'http://www.w3.org/2000/svg';
427
-
428
- var SVG_TAGS = {
429
- svg: 1, g: 1, path: 1, circle: 1, ellipse: 1, rect: 1, line: 1,
430
- polyline: 1, polygon: 1, defs: 1, title: 1, desc: 1,
431
- lineargradient: 1, radialgradient: 1, stop: 1, clippath: 1, mask: 1
432
- };
356
+ /* ------------------------------------------------- branding (optional file) */
433
357
 
434
- // Presentation/geometry only. No href/xlink:href in any form, no on* events.
435
- var SVG_ATTRS = {
436
- viewbox: 'viewBox', preserveaspectratio: 'preserveAspectRatio',
437
- xmlns: 'xmlns', version: 'version',
438
- d: 'd', fill: 'fill', 'fill-rule': 'fill-rule', 'fill-opacity': 'fill-opacity',
439
- 'clip-rule': 'clip-rule', 'clip-path': 'clip-path', mask: 'mask',
440
- stroke: 'stroke', 'stroke-width': 'stroke-width', 'stroke-linecap': 'stroke-linecap',
441
- 'stroke-linejoin': 'stroke-linejoin', 'stroke-dasharray': 'stroke-dasharray',
442
- 'stroke-dashoffset': 'stroke-dashoffset', 'stroke-opacity': 'stroke-opacity',
443
- 'stroke-miterlimit': 'stroke-miterlimit',
444
- opacity: 'opacity', transform: 'transform',
445
- x: 'x', y: 'y', x1: 'x1', y1: 'y1', x2: 'x2', y2: 'y2',
446
- cx: 'cx', cy: 'cy', r: 'r', rx: 'rx', ry: 'ry',
447
- width: 'width', height: 'height', points: 'points',
448
- offset: 'offset', 'stop-color': 'stop-color', 'stop-opacity': 'stop-opacity',
449
- gradientunits: 'gradientUnits', gradienttransform: 'gradientTransform',
450
- spreadmethod: 'spreadMethod', clippathunits: 'clipPathUnits',
451
- maskunits: 'maskUnits', maskcontentunits: 'maskContentUnits',
452
- id: 'id', 'class': 'class'
453
- };
358
+ /* Branding lives in src/ck-ui-branding.js and is OPTIONAL. Everything below
359
+ asks for the extension and does nothing when it is absent, so a build
360
+ without that file draws no logo, no attribution line and no branding CSS —
361
+ and errors nowhere. See that file's header for the load-order contract.
454
362
 
455
- /* Every sanitised logo gets a unique id namespace.
456
-
457
- buildBrandLogo() runs twice per mount (banner + panel head), and doubles
458
- again when logoDark is set — so one shadow root can hold four copies of the
459
- same asset. A gradient/clipPath/mask id like "g" would then appear four
460
- times, and url(#g) resolves to the FIRST match in the tree: the dark logo
461
- would silently paint with the light logo's gradient stops. Prefixing every
462
- id per instance, and rewriting the url(#…) references in the same pass,
463
- keeps each copy self-contained. */
464
- var svgSeq = 0;
465
-
466
- // url(#localRef) and plain values only — no url(http…), no javascript:.
467
- // `prefix` namespaces id definitions and their url(#…) references together.
468
- function safeAttrValue(name, value, prefix) {
469
- var v = String(value == null ? '' : value);
470
- // Strip nothing; reject outright. Control chars are how javascript: is hidden.
471
- var probe = v.replace(/[\u0000-\u0020\u007f-\u00a0]/g, '').toLowerCase();
472
- if (probe.indexOf('javascript:') !== -1) return null;
473
- if (probe.indexOf('data:text') !== -1) return null;
474
- if (probe.indexOf('&#') !== -1) return null;
475
- // Any url() must be a same-document fragment reference.
476
- if (probe.indexOf('url(') !== -1 && !/^url\(#[a-z0-9_.:-]+\)$/i.test(probe)) return null;
477
- if (name === 'id') {
478
- if (!/^[a-zA-Z][\w.:-]*$/.test(v)) return null;
479
- return prefix + v;
480
- }
481
- // Rewrite a reference so it points at THIS instance's namespaced definition.
482
- var m = /^url\(#([\w.:-]+)\)$/.exec(v);
483
- if (m) return 'url(#' + prefix + m[1] + ')';
484
- return v;
485
- }
486
-
487
- function rebuildSvgNode(src, out, depth, prefix) {
488
- if (depth > 24) return false; // pathological nesting
489
- var kids = src.childNodes;
490
- for (var i = 0; i < kids.length; i++) {
491
- var n = kids[i];
492
- if (n.nodeType === 3) { // text (only inside title/desc)
493
- var pt = out.nodeName.toLowerCase();
494
- if (pt === 'title' || pt === 'desc') out.appendChild(document.createTextNode(n.nodeValue));
495
- continue;
496
- }
497
- if (n.nodeType !== 1) continue; // drop comments, CDATA, PIs
498
- var tag = String(n.nodeName || '').toLowerCase();
499
- if (!Object.prototype.hasOwnProperty.call(SVG_TAGS, tag)) return false; // bail, don't skip
500
- var fresh = document.createElementNS(SVG_NS, n.nodeName);
501
- var attrs = n.attributes || [];
502
- for (var a = 0; a < attrs.length; a++) {
503
- var an = String(attrs[a].name || '').toLowerCase();
504
- if (/^on/i.test(an)) return false; // event handler present -> reject whole logo
505
- if (an === 'href' || an === 'xlink:href' || an.indexOf('xlink') === 0) return false;
506
- if (!Object.prototype.hasOwnProperty.call(SVG_ATTRS, an)) continue; // unknown -> just omit
507
- var val = safeAttrValue(an, attrs[a].value, prefix);
508
- if (val === null) continue;
509
- try { fresh.setAttribute(SVG_ATTRS[an], val); } catch (e) { /* ignore */ }
510
- }
511
- if (!rebuildSvgNode(n, fresh, depth + 1, prefix)) return false;
512
- out.appendChild(fresh);
513
- }
514
- return true;
515
- }
516
-
517
- // Raw SVG string -> freshly built, safe <svg> element, or null.
518
- function sanitizeSvg(markup) {
519
- var s = str(markup);
520
- if (!s || s.length > 512 * 1024) return null;
521
- if (!/^\s*<svg[\s>]/i.test(s)) return null; // must be an SVG root
522
- var doc;
523
- try {
524
- doc = new DOMParser().parseFromString(s, 'image/svg+xml');
525
- } catch (e) { return null; }
526
- if (!doc) return null;
527
- if (doc.getElementsByTagName('parsererror').length) return null;
528
- var srcRoot = doc.documentElement;
529
- if (!srcRoot || String(srcRoot.nodeName).toLowerCase() !== 'svg') return null;
530
-
531
- // Unique per sanitised instance, so four copies of one asset never collide.
532
- var prefix = 'ck' + (++svgSeq) + '-';
533
-
534
- var svg = document.createElementNS(SVG_NS, 'svg');
535
- var ra = srcRoot.attributes || [];
536
- for (var i = 0; i < ra.length; i++) {
537
- var an = String(ra[i].name || '').toLowerCase();
538
- if (/^on/i.test(an)) return null;
539
- if (an.indexOf('xlink') === 0 || an === 'href') return null;
540
- if (!Object.prototype.hasOwnProperty.call(SVG_ATTRS, an)) continue;
541
- var val = safeAttrValue(an, ra[i].value, prefix);
542
- if (val === null) continue;
543
- try { svg.setAttribute(SVG_ATTRS[an], val); } catch (e) {}
544
- }
545
- if (!rebuildSvgNode(srcRoot, svg, 0, prefix)) return null;
546
- return svg;
547
- }
548
-
549
- /* Image-source logos.
550
- Only http(s) and image data: URIs. data:text/html is a navigation/XSS
551
- vector via <img>-adjacent contexts, and any other scheme is rejected.
552
-
553
- NOTE FOR INTEGRATORS: an https:// logo is an external network request that
554
- fires BEFORE the visitor has consented to anything. It leaks IP, User-Agent
555
- and Referer to whoever hosts the file. ConsentKit therefore recommends an
556
- inline SVG string or a data: URI, both of which are entirely local. An
557
- external URL still works — it is the integrator's call, made knowingly —
558
- and we send referrerpolicy=no-referrer to reduce what leaks. */
559
- function safeImgSrc(value) {
560
- var v = str(value);
561
- if (!v) return null;
562
- if (/^https?:\/\//i.test(v)) return v;
563
- if (/^data:image\/(svg\+xml|png|jpe?g|webp|gif|avif)[;,]/i.test(v)) return v;
564
- return null;
565
- }
566
-
567
- // Default 18px and a 32px ceiling: the logo is a signature, not a header.
568
- // Anything taller starts competing with the banner title.
569
- function clampLogoHeight(v) {
570
- var n = (typeof v === 'number') ? v : parseFloat(v);
571
- if (!isFinite(n)) return 18;
572
- if (n < 14) return 14;
573
- if (n > 32) return 32;
574
- return Math.round(n);
575
- }
576
-
577
- // Only http(s) links are made clickable; javascript:/data: never become hrefs.
578
- function safeLinkUrl(value) {
579
- var v = str(value);
580
- if (!v) return null;
581
- return /^https?:\/\//i.test(v) ? v : null;
582
- }
583
-
584
- function brandingCfg(cfg) {
585
- var b = cfg && cfg.branding;
586
- return (b && typeof b === 'object' && !Array.isArray(b)) ? b : null;
363
+ The extension is handed `host` on every DOM-producing call: ck-ui owns el()
364
+ and the localised strings (T is reassigned per mount), so passing them in
365
+ keeps one dictionary and one set of helpers rather than two copies. */
366
+ function brandingExt() {
367
+ var ck = api();
368
+ var ext = ck && ck._uiExtensions;
369
+ var b = ext && ext.branding;
370
+ return (b && typeof b === 'object') ? b : null;
587
371
  }
588
372
 
589
- // One logo node (inline SVG or <img>), already sanitised. null when unusable.
590
- function buildLogoNode(source, alt, decorative) {
591
- if (!source) return null;
592
- var node = null;
593
- var s = str(source);
594
- if (!s) return null;
595
-
596
- if (/^\s*</.test(s)) {
597
- node = sanitizeSvg(s); // raw markup -> rebuilt SVG
598
- if (node) node.classList.add('ck-brand__logo');
599
- } else {
600
- var src = safeImgSrc(s);
601
- if (!src) return null;
602
- node = el('img', 'ck-brand__logo');
603
- node.setAttribute('referrerpolicy', 'no-referrer');
604
- node.setAttribute('decoding', 'async');
605
- node.src = src;
606
- node.alt = decorative ? '' : (alt || '');
607
- }
608
- // The SVG carries no accessible name of its own; the wrapper supplies one
609
- // (or hides it, when a sibling already names the logo).
610
- if (node && node.nodeName.toLowerCase() === 'svg') {
611
- node.setAttribute('aria-hidden', 'true');
612
- node.setAttribute('focusable', 'false');
613
- }
614
- return node;
373
+ function extHost() {
374
+ return { el: el, str: str, T: T };
615
375
  }
616
376
 
617
- /* Logo block for a banner/panel header.
618
-
619
- Dark theme: branding.logoDark, when supplied, is rendered as a second node
620
- and swapped purely in CSS. When it is absent the single main logo shows in
621
- both themes — which is why the shipped ECOM Consult asset (wordmark
622
- fill="white", built for dark backgrounds) belongs in logoDark, with a
623
- dark-ink variant in logo. An <img>/data: logo cannot be recoloured by our
624
- CSS at all, so two assets are the only route there; an inline SVG could in
625
- principle inherit currentColor, but only if the asset is authored that way. */
626
377
  function buildBrandLogo(cfg) {
627
- var b = brandingCfg(cfg);
628
- if (!b) return null;
629
-
630
- var alt = str(b.logoAlt) || '';
631
- var main = buildLogoNode(b.logo, alt, false);
632
- if (!main) return null; // no valid logo -> render nothing
633
-
634
- var dark = buildLogoNode(b.logoDark, alt, false);
635
-
636
- var wrap = el('div', 'ck-brand');
637
- if (dark) {
638
- wrap.classList.add('ck-brand__has-dark');
639
- main.classList.add('ck-brand__light');
640
- dark.classList.add('ck-brand__dark');
641
- }
378
+ var b = brandingExt();
379
+ return (b && typeof b.buildBrandLogo === 'function') ? b.buildBrandLogo(cfg, extHost()) : null;
380
+ }
642
381
 
643
- var link = safeLinkUrl(b.logoUrl);
644
- var host_ = wrap;
645
- if (link) {
646
- var a = el('a', 'ck-brand__link');
647
- a.href = link;
648
- a.target = '_blank';
649
- a.rel = 'noopener noreferrer';
650
- // Links are focusable by nature; the accessible name comes from logoAlt.
651
- a.setAttribute('aria-label', alt || 'ConsentKit');
652
- host_ = a;
653
- wrap.appendChild(a);
654
- }
655
- host_.appendChild(main);
656
- if (dark) host_.appendChild(dark);
657
-
658
- // A non-linked logo must not be a tab stop. The <svg>/<img> is aria-hidden
659
- // or alt="", so a visually-hidden-free text alternative is supplied here
660
- // for the image case only when it is not already announced by the <img> alt.
661
- if (!link && alt && main.nodeName.toLowerCase() === 'svg') {
662
- wrap.setAttribute('role', 'img');
663
- wrap.setAttribute('aria-label', alt);
664
- }
665
- return wrap;
382
+ function buildPoweredBy(cfg) {
383
+ var b = brandingExt();
384
+ return (b && typeof b.buildPoweredBy === 'function') ? b.buildPoweredBy(cfg, extHost()) : null;
666
385
  }
667
386
 
668
- // Per-mount CSS for logo height + the dark/light swap. Mirrors buildThemeCss.
669
387
  function buildBrandCss(cfg) {
670
- var b = brandingCfg(cfg);
671
- if (!b) return '';
672
- var h = clampLogoHeight(b.logoHeight);
673
- var out = [':host{--ck-logo-h:' + h + 'px}'];
674
-
675
- var theme = (cfg && cfg.theme) || {};
676
- var mode = theme.mode;
677
- if (mode !== 'light' && mode !== 'dark') mode = 'auto';
678
-
679
- // Same cascade shape as buildThemeCss so the logo always agrees with the
680
- // palette: auto mode follows prefers-color-scheme but a forced .ck-mode-light
681
- // still wins, and .ck-mode-dark forces the dark asset outright.
682
- function swap(prefix) {
683
- return prefix + ' .ck-brand__has-dark .ck-brand__light{display:none}\n' +
684
- prefix + ' .ck-brand__has-dark .ck-brand__dark{display:block}';
685
- }
686
- if (mode === 'auto') {
687
- out.push('@media (prefers-color-scheme: dark){\n' +
688
- swap(':host(:not(.ck-mode-light))') + '\n}');
689
- }
690
- out.push(swap(':host(.ck-mode-dark)'));
691
- return out.join('\n');
388
+ var b = brandingExt();
389
+ return (b && typeof b.buildBrandCss === 'function') ? (b.buildBrandCss(cfg) || '') : '';
692
390
  }
693
391
 
694
- /* Powered-by line. true -> localised default; object -> caller's text/url.
695
- Rendered after the actions in DOM order and styled quiet on purpose. */
696
- function buildPoweredBy(cfg) {
697
- var b = brandingCfg(cfg);
698
- if (!b) return null;
699
- var pb = b.poweredBy;
700
- if (!pb) return null; // false/undefined -> nothing
701
-
702
- var text, url = null;
703
- if (pb === true) {
704
- text = T.poweredBy; // en fallback guaranteed by STR_KEYS
705
- } else if (typeof pb === 'object' && !Array.isArray(pb)) {
706
- text = str(pb.text) || T.poweredBy;
707
- url = safeLinkUrl(pb.url);
708
- } else {
709
- return null;
710
- }
392
+ /* Static branding rules, appended to the base sheet at mount. Empty string
393
+ when the extension is absent no dead .ck-brand CSS in the build. */
394
+ function brandingCss() {
395
+ var b = brandingExt();
396
+ return (b && typeof b.css === 'function') ? ('\n' + b.css()) : '';
397
+ }
711
398
 
712
- var p = el('p', 'ck-powered');
713
- if (url) {
714
- var a = el('a', null, text);
715
- a.href = url;
716
- a.target = '_blank';
717
- a.rel = 'noopener noreferrer';
718
- p.appendChild(a);
719
- } else {
720
- p.appendChild(document.createTextNode(text));
721
- }
722
- return p;
399
+ /* Structural: branding produces DOM, so a config that gains a logo must
400
+ remount. '-' whenever there is no branding at all, which keeps an
401
+ unbranded page's signature identical to what it was before the split. */
402
+ function brandSignature(cfg) {
403
+ var b = brandingExt();
404
+ return (b && typeof b.brandSignature === 'function') ? b.brandSignature(cfg) : '-';
723
405
  }
724
406
 
725
407
  function safeState() {
@@ -779,20 +461,138 @@
779
461
  /* ----------------------------------------------------------------- theme */
780
462
 
781
463
  // Built-in palettes. Dark values are picked for >= 4.5:1 text contrast.
782
- 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
+
783
472
  var LIGHT = {
784
- bg: '#fff', ink: '#1B2437', accent: '#2B50D8', onAccent: '#fff',
473
+ bg: '#ffffff', ink: '#1B2437', accent: '#2B50D8', onAccent: '#ffffff',
785
474
  muted: '#5b6478', line: '#dfe3ea', soft: '#f4f6f9'
786
475
  };
787
476
  var DARK = {
788
- bg: '#1A202D', ink: '#E6EAF4', accent: '#7B96F0', onAccent: '#12182A',
789
- muted: '#A6B0C6', line: '#333C4F', soft: '#232B3A'
477
+ bg: '#1c1c1e', ink: '#E9E9EB', accent: '#7B96F0', onAccent: '#12182A',
478
+ muted: '#A0A0A8', line: '#3A3A3C', soft: '#2C2C2E'
790
479
  };
791
480
 
792
481
  function str(v) {
793
482
  return (typeof v === 'string' && v.trim()) ? v.trim() : null;
794
483
  }
795
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
+
796
596
  /* Config values are interpolated into the TEXT of a generated stylesheet, so
797
597
  an unvalidated value can close the declaration and open rules of its own
798
598
  ("10px;}.ck-btn--filled{display:none" hides "Reject all"). config.theme is
@@ -803,10 +603,11 @@
803
603
  fit — a broken colour is a cosmetic bug, an injected rule is a defacement
804
604
  and can strip the reject button, which is a consent-validity problem.
805
605
 
806
- Sanitising happens at the entry points in buildThemeCss (the eight
807
- config reads), not in tokenBlock(): by the time values reach derive() and
808
- color-mix() they are already clean, and the built-in constants and
809
- 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. */
810
611
  var RE_CSS = {
811
612
  // #RGB / #RRGGBB / #RRGGBBAA (and #RGBA), rgb()/rgba()/hsl()/hsla() with
812
613
  // numbers, commas, spaces, %, decimals and slashes, or a bare colour name.
@@ -825,8 +626,179 @@
825
626
  return v;
826
627
  }
827
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
+
828
800
  // One :host{} block of custom properties for a resolved palette.
829
- function tokenBlock(sel, p, radius) {
801
+ function tokenBlock(sel, p, extra) {
830
802
  var d = [
831
803
  '--ck-bg:' + p.bg,
832
804
  '--ck-ink:' + p.ink,
@@ -836,7 +808,28 @@
836
808
  '--ck-line:' + p.line,
837
809
  '--ck-soft:' + p.soft
838
810
  ];
839
- 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
+ }
840
833
  return sel + '{' + d.join(';') + '}';
841
834
  }
842
835
 
@@ -858,11 +851,9 @@
858
851
  function buildThemeCss(cfg) {
859
852
  var theme = (cfg && cfg.theme) || {};
860
853
  var dk = (theme.dark && typeof theme.dark === 'object') ? theme.dark : {};
861
- // Every value below is interpolated into stylesheet text — see
862
- // sanitizeCssValue(). A rejected value falls back to the token default, so
863
- // derive()'s "bg !== base.bg" check collapses to exactly the built-in
864
- // palette rather than a half-substituted one.
865
- var radius = sanitizeCssValue('length', theme.radius, LIGHT_RADIUS);
854
+
855
+ var radius = resolveRadius(theme);
856
+ var font = resolveFont(theme);
866
857
 
867
858
  // Light: config overrides on top of the built-in light palette.
868
859
  var light = derive(LIGHT,
@@ -872,17 +863,37 @@
872
863
 
873
864
  // Dark: theme.dark overrides on top of the built-in dark palette.
874
865
  // A light-only theme.accent deliberately does NOT carry into dark — the
875
- // 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.
876
867
  var dark = derive(DARK,
877
868
  sanitizeCssValue('color', dk.bg, DARK.bg),
878
869
  sanitizeCssValue('color', dk.ink, DARK.ink));
879
870
  if (str(dk.accent)) dark.accent = sanitizeCssValue('color', dk.accent, DARK.accent);
880
871
  if (str(dk.onAccent)) dark.onAccent = sanitizeCssValue('color', dk.onAccent, DARK.onAccent);
881
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
+
882
883
  var mode = theme.mode;
883
884
  if (mode !== 'light' && mode !== 'dark') mode = 'auto';
884
885
 
885
- 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)];
886
897
  if (mode === 'auto') {
887
898
  // forced-light class must still beat a dark system preference
888
899
  out.push('@media (prefers-color-scheme: dark){' +
@@ -901,7 +912,8 @@
901
912
  ':host(.ck-mode-dark){--ck-muted:' + DARK.muted + ';--ck-line:' + DARK.line +
902
913
  ';--ck-soft:' + DARK.soft + '}}');
903
914
 
904
- return { css: out.join('\n'), mode: mode };
915
+ return { css: out.join('\n'), mode: mode, radius: radius, font: font,
916
+ light: lightBtn, dark: darkBtn };
905
917
  }
906
918
 
907
919
  function applyTheme(cfg) {
@@ -1002,6 +1014,33 @@
1002
1014
  return wrap;
1003
1015
  }
1004
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
+
1005
1044
  // Unknown type -> bar/bottom. Known type with an unrecognized position ->
1006
1045
  // that type's own default (bar: bottom, box: bottom-left — the side away
1007
1046
  // from the chat widgets and scroll-to-top buttons most sites put on the right).
@@ -1036,10 +1075,26 @@
1036
1075
  body.appendChild(h);
1037
1076
 
1038
1077
  var p = el('p');
1039
- p.appendChild(document.createTextNode(T.bannerText + ' '));
1040
- var link = el('a', 'ck-banner__more', T.more);
1041
- link.href = '#';
1042
- 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
+ }
1043
1098
  body.appendChild(p);
1044
1099
 
1045
1100
  var pb = buildPoweredBy(cfg);
@@ -1061,11 +1116,11 @@
1061
1116
  b.appendChild(body);
1062
1117
 
1063
1118
  var actions = el('div', 'ck-actions');
1064
- var accept = el('button', 'ck-btn ck-btn--filled', T.acceptAll);
1119
+ var accept = el('button', 'ck-btn ck-btn--accept', T.acceptAll);
1065
1120
  accept.type = 'button';
1066
- var reject = el('button', 'ck-btn ck-btn--filled', T.rejectAll);
1121
+ var reject = el('button', 'ck-btn ck-btn--reject', T.rejectAll);
1067
1122
  reject.type = 'button';
1068
- var custom = el('button', 'ck-btn ck-btn--outline', T.customize);
1123
+ var custom = el('button', 'ck-btn ck-btn--settings', T.customize);
1069
1124
  custom.type = 'button';
1070
1125
 
1071
1126
  accept.addEventListener('click', function () { doAcceptAll(); });
@@ -1289,25 +1344,9 @@
1289
1344
  // Structural inputs: a change to any of these needs a rebuild, not a restyle.
1290
1345
  // Branding belongs here because it produces DOM, not just styling: mount() is
1291
1346
  // one-shot, so a config that gains a logo after the first ck:init would
1292
- // otherwise take the applyTheme()-only path and never render it.
1293
- function brandSignature(cfg) {
1294
- var b = brandingCfg(cfg);
1295
- if (!b) return '-';
1296
- var pb = b.poweredBy;
1297
- var pbSig = (pb && typeof pb === 'object')
1298
- ? 'o:' + String(pb.text || '') + ':' + String(pb.url || '')
1299
- : String(!!pb);
1300
- // Logos are hashed by length + head so a long data: URI does not bloat the key.
1301
- function tag(v) {
1302
- var s = str(v);
1303
- return s ? (s.length + ':' + s.slice(0, 32)) : '-';
1304
- }
1305
- return [
1306
- tag(b.logo), tag(b.logoDark), String(b.logoAlt || ''),
1307
- String(clampLogoHeight(b.logoHeight)), String(b.logoUrl || ''), pbSig
1308
- ].join('~');
1309
- }
1310
-
1347
+ // otherwise take the applyTheme()-only path and never render it. The signature
1348
+ // itself is computed by the branding extension (and is '-' without it), so an
1349
+ // unbranded page's signature is byte-identical to what it was before the split.
1311
1350
  function signature(cfg) {
1312
1351
  var c = cfg || {};
1313
1352
  var lay = resolveLayout(c);
@@ -1316,6 +1355,10 @@
1316
1355
  String(c.language || 'auto'),
1317
1356
  lay.type, String(lay.position),
1318
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,
1319
1362
  brandSignature(c)
1320
1363
  ].join('|');
1321
1364
  }
@@ -1347,7 +1390,10 @@
1347
1390
  root.innerHTML = '';
1348
1391
 
1349
1392
  var style = document.createElement('style');
1350
- style.textContent = CSS;
1393
+ // Base sheet plus the branding rules, when the branding file is loaded.
1394
+ // Without it brandingCss() is '' and the shadow root carries no .ck-brand,
1395
+ // .ck-foot or .ck-powered rules at all.
1396
+ style.textContent = CSS + brandingCss();
1351
1397
  root.appendChild(style);
1352
1398
 
1353
1399
  switches = {};
@@ -1369,6 +1415,33 @@
1369
1415
 
1370
1416
  /* ---------------------------------------------------------------- events */
1371
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
+
1372
1445
  // SSR-safe: with no DOM there is nothing to render or listen to, so importing
1373
1446
  // this file in Node is a no-op rather than a throw (mirrors the core).
1374
1447
  if (typeof document === 'undefined') return;