@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/README.md +262 -20
- package/npm/core.mjs +4 -1
- package/npm/index.cjs +4 -1
- package/npm/index.d.ts +228 -2
- package/npm/index.mjs +14 -2
- package/npm/internal-stub.mjs +12 -1
- package/package.json +1 -1
- package/src/ck-core.js +524 -38
- package/src/ck-debug.js +137 -3
- package/src/ck-saas.js +36 -1
- package/src/ck-ui-branding.js +511 -0
- package/src/ck-ui.js +530 -457
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:
|
|
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
|
-
|
|
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:
|
|
195
|
-
|
|
196
|
-
'
|
|
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
|
-
/*
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
'.ck-banner--box .ck-
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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
|
-
'.
|
|
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
|
-
/*
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
590
|
-
|
|
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 =
|
|
628
|
-
|
|
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
|
-
|
|
644
|
-
var
|
|
645
|
-
|
|
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 =
|
|
671
|
-
|
|
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
|
-
/*
|
|
695
|
-
|
|
696
|
-
function
|
|
697
|
-
var b =
|
|
698
|
-
|
|
699
|
-
|
|
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
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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
|
-
|
|
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: '#
|
|
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: '#
|
|
789
|
-
muted: '#
|
|
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
|
|
807
|
-
|
|
808
|
-
color-mix() they are already clean, and the
|
|
809
|
-
generated color-mix() strings must not be
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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 #
|
|
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
|
-
|
|
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
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
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--
|
|
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--
|
|
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--
|
|
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
|
-
|
|
1294
|
-
|
|
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
|
-
|
|
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;
|