PluginProbe
Darkify – Dark Mode & Night Mode for Website & Admin (Dark Theme Included) / 2.1.1
Darkify – Dark Mode & Night Mode for Website & Admin (Dark Theme Included) v2.1.1
2.1.3 2.1.2 2.1.1 2.1.0 2.0.4 2.0.3 2.0.2 2.0.1 2.0.0 1.5.5 1.5.4 1.5.3 1.5.2 1.5.1 1.5.0 trunk 1.0.1 1.1.0 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.2.5 1.3.0 All 57 releases
darkify / src / assets / js / client_main.js

client_main.js in Darkify – Dark Mode & Night Mode for Website & Admin (Dark Theme Included) 2.1.1, at src/assets/js/client_main.js

5,750 lines 207.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 "use strict";
2
3 // Skip ALL Darkify initialization when this page is running inside a frontend
4 // iframe and the parent site has "Frontend Iframe Dark Mode" turned OFF.
5 // This is the only reliable guard for same-origin embedded pages that have
6 // their own copy of Darkify running — they must not self-initialize dark mode.
7 var _dkf_iframe_disabled = (
8 window !== window.top &&
9 typeof darkify_is_this_admin_panel !== "undefined" &&
10 darkify_is_this_admin_panel !== "1" &&
11 typeof darkify_enable_frontend_iframe_dark_mode !== "undefined" &&
12 darkify_enable_frontend_iframe_dark_mode !== "1"
13 );
14
15 let has_process_run_at_least_once = false;
16 let old_transition = "";
17 let has_background_img_url = false;
18
19 /* ==========================================================================
20 Developer diagnostics
21 --------------------------------------------------------------------------
22 Off by default and off in production. The whole layer is gated behind one
23 boolean resolved once at startup; when it is false every instrumentation
24 point is a single `if` against a constant that the JIT folds away, and no
25 timers, no counters and no console output exist. Nothing here runs for a
26 visitor.
27
28 Turn it on per-browser with `?darkify_debug=1` on any URL (it persists for
29 the session), or from the console with:
30
31 localStorage.darkify_debug = "1" // then reload
32 delete localStorage.darkify_debug // to turn it back off
33
34 Then inspect from the console:
35
36 darkifyDebug.report() // summary table: passes, timings, counts
37 darkifyDebug.elements() // the elements this engine processed
38 darkifyDebug.css() // generated pseudo-element CSS + its size
39 darkifyDebug.reset() // zero the counters
40
41 This exists because the failure mode these numbers describe — an engine
42 quietly doing quadratic work — is invisible from the page. Nothing renders
43 wrong; the tab just stops responding, and without counters there is no way
44 for a site owner or a support agent to tell an engine problem from a theme
45 problem. The counters below are the same ones used to measure this release.
46 ========================================================================== */
47
48 const DARKIFY_DEBUG = (function () {
49 try {
50 if (
51 typeof location !== "undefined" &&
52 location.search.indexOf("darkify_debug=1") !== -1
53 ) {
54 localStorage.darkify_debug = "1";
55 return true;
56 }
57 if (
58 typeof location !== "undefined" &&
59 location.search.indexOf("darkify_debug=0") !== -1
60 ) {
61 delete localStorage.darkify_debug;
62 return false;
63 }
64 return localStorage.darkify_debug === "1";
65 } catch (e) {
66 return false;
67 }
68 })();
69
70 const darkify_debug_state = DARKIFY_DEBUG
71 ? {
72 started: (typeof performance !== "undefined" ? performance.now() : 0),
73 full_walks: 0,
74 incremental_walks: 0,
75 state_sweeps: 0,
76 class_redrives: 0,
77 elements_processed: 0,
78 elements_reprocessed: 0,
79 observer_callbacks: 0,
80 nodes_queued: 0,
81 walk_ms: 0,
82 sweep_ms: 0,
83 errors: [],
84 }
85 : null;
86
87 /** Time `fn`, adding the elapsed milliseconds to `bucket`. Debug builds only. */
88 function darkify_debug_time(bucket, fn) {
89 if (!DARKIFY_DEBUG) {
90 return fn();
91 }
92 var t = performance.now();
93 try {
94 return fn();
95 } finally {
96 darkify_debug_state[bucket] += performance.now() - t;
97 }
98 }
99
100 function darkify_debug_count(key, n) {
101 if (DARKIFY_DEBUG) {
102 darkify_debug_state[key] += n === undefined ? 1 : n;
103 }
104 }
105
106 function darkify_debug_error(where, err) {
107 if (DARKIFY_DEBUG) {
108 darkify_debug_state.errors.push(where + ": " + (err && err.message ? err.message : err));
109 }
110 }
111
112 if (DARKIFY_DEBUG) {
113 window.darkifyDebug = {
114 /** Raw counters, if you want to diff them yourself. */
115 state: darkify_debug_state,
116
117 report: function () {
118 var s = darkify_debug_state;
119 var processed = document.querySelectorAll(".darkify_processed").length;
120 var inline = document.querySelectorAll("[style]").length;
121 var pseudo = document.querySelectorAll("[data-darkify-pseudo]").length;
122 var css = this.css();
123
124 var rows = {
125 "dark mode active": darkify_is_dark(),
126 "elements in document": document.getElementsByTagName("*").length,
127 "elements processed (marked)": processed,
128 "process calls (incl. re-process)": s.elements_processed,
129 "re-processed after class change": s.elements_reprocessed,
130 "full DOM walks": s.full_walks,
131 "incremental walks": s.incremental_walks,
132 "state sweeps (dark<->light)": s.state_sweeps,
133 "class-change redrives": s.class_redrives,
134 "observer callbacks": s.observer_callbacks,
135 "nodes queued by observer": s.nodes_queued,
136 "time in walks (ms)": +s.walk_ms.toFixed(1),
137 "time in state sweeps (ms)": +s.sweep_ms.toFixed(1),
138 "elements with inline style": inline,
139 "pseudo-element rules written": pseudo,
140 "generated CSS (bytes)": css.bytes,
141 errors: s.errors.length,
142 };
143
144 if (typeof console.table === "function") {
145 console.table(rows);
146 } else {
147 console.log(rows);
148 }
149 if (s.errors.length) {
150 console.warn("Darkify errors:", s.errors);
151 }
152 return rows;
153 },
154
155 /** The elements this engine has classified, with the class it assigned. */
156 elements: function () {
157 return Array.from(document.querySelectorAll(".darkify_processed")).map(
158 function (el) {
159 return {
160 el: el,
161 tag: el.nodeName.toLowerCase(),
162 darkify: Array.from(el.classList)
163 .filter(function (c) {
164 return c.indexOf("darkify_") === 0;
165 })
166 .join(" "),
167 };
168 },
169 );
170 },
171
172 /** The stylesheet the engine generates for pseudo-elements, and its size. */
173 css: function () {
174 // Every stylesheet this engine injects, so the reported size is the
175 // engine's real CSS footprint rather than one of its sheets.
176 var ids = [
177 "darkify-pseudo-surfaces",
178 "darkify_blend_guard_style",
179 "darkify-iframe-css",
180 "darkify-iframe-vars",
181 "darkify-block-editor-css",
182 ];
183 var text = "";
184 var per = {};
185 ids.forEach(function (id) {
186 var el = document.getElementById(id);
187 var t = el ? el.textContent : "";
188 if (t) {
189 per[id] = t.length;
190 text += t;
191 }
192 });
193 return { bytes: text.length, sheets: per, text: text };
194 },
195
196 reset: function () {
197 Object.keys(darkify_debug_state).forEach(function (k) {
198 if (typeof darkify_debug_state[k] === "number") {
199 darkify_debug_state[k] = 0;
200 }
201 });
202 darkify_debug_state.errors.length = 0;
203 },
204 };
205
206 console.log(
207 "%cDarkify debug enabled%c — run darkifyDebug.report() for engine stats, " +
208 "darkifyDebug.elements() for processed elements. " +
209 "Disable with ?darkify_debug=0",
210 "background:#2154ea;color:#fff;padding:2px 6px;border-radius:3px",
211 "",
212 );
213 }
214 let darken_level = parseInt(darkify_bg_image_darken_to) / 100;
215 darken_level = darken_level.toFixed(1);
216 let darkify_secondary_bg_color = "";
217
218 /* ==========================================================================
219 Colour transform helpers.
220 --------------------------------------------------------------------------
221 Everything else in this engine repaints an element by stamping a
222 `darkify_style_*` class on it and letting client_main.css win the cascade.
223 That doesn't reach generated boxes (`::before`/`::after`), which have no
224 element of their own for a class to land on — darkify_transform_color() and
225 darkify_recolor_gradient() below are what darkify_pseudo_declarations()
226 uses to give those a dark-mode colour/gradient instead.
227 ========================================================================== */
228
229 var darkify_gradient_mode = "recolor";
230
231 /**
232 * This whole layer is frontend-only.
233 *
234 * wp-admin's colours are WordPress's own chrome, already handled by the
235 * class-based repaint, and re-deriving them here only fights it — an editor
236 * full of preserved accents turns into one tinted wash, because an editor is
237 * thousands of small nodes each carrying an accent that this layer would
238 * faithfully preserve. So the admin keeps exactly the behaviour it had before
239 * this layer existed.
240 */
241 var darkify_adaptive_layer_enabled = !(
242 typeof darkify_is_this_admin_panel !== "undefined" &&
243 darkify_is_this_admin_panel === "1"
244 );
245
246 /** Matches one functional colour token inside a longer value (a gradient stop). */
247 var DARKIFY_COLOR_TOKEN = /rgba?\([^()]*\)/g;
248
249 /**
250 * Parse any colour the engine can meet — computed `rgb()`/`rgba()` (comma or
251 * space syntax) and the hex an admin field stores.
252 *
253 * @return {{r:number,g:number,b:number,a:number}|null}
254 */
255 function darkify_parse_color(value) {
256 if (!value) {
257 return null;
258 }
259
260 var str = String(value).trim().toLowerCase();
261
262 if (str === "transparent") {
263 return { r: 0, g: 0, b: 0, a: 0 };
264 }
265
266 var hex = str.match(/^#([0-9a-f]{3,8})$/);
267 if (hex) {
268 var digits = hex[1];
269 if (digits.length === 3 || digits.length === 4) {
270 digits = digits
271 .split("")
272 .map(function (c) {
273 return c + c;
274 })
275 .join("");
276 }
277 if (digits.length !== 6 && digits.length !== 8) {
278 return null;
279 }
280 return {
281 r: parseInt(digits.slice(0, 2), 16),
282 g: parseInt(digits.slice(2, 4), 16),
283 b: parseInt(digits.slice(4, 6), 16),
284 a: digits.length === 8 ? parseInt(digits.slice(6, 8), 16) / 255 : 1,
285 };
286 }
287
288 var fn = str.match(/^rgba?\(([^)]*)\)$/);
289 if (!fn) {
290 return null;
291 }
292
293 var parts = fn[1].replace(/\//g, " ").split(/[\s,]+/).filter(Boolean);
294 if (parts.length < 3) {
295 return null;
296 }
297
298 var channel = function (part) {
299 var n =
300 part.indexOf("%") !== -1
301 ? (parseFloat(part) * 255) / 100
302 : parseFloat(part);
303 return isNaN(n) ? 0 : n;
304 };
305
306 var alpha = 1;
307 if (parts.length > 3) {
308 alpha =
309 parts[3].indexOf("%") !== -1
310 ? parseFloat(parts[3]) / 100
311 : parseFloat(parts[3]);
312 if (isNaN(alpha)) {
313 alpha = 1;
314 }
315 }
316
317 return {
318 r: channel(parts[0]),
319 g: channel(parts[1]),
320 b: channel(parts[2]),
321 a: alpha,
322 };
323 }
324
325 /** `#rrggbb` form of any parseable colour — the override map's key. */
326 function darkify_normalize_color(value) {
327 var color = darkify_parse_color(value);
328 if (!color) {
329 return "";
330 }
331
332 var byte = function (n) {
333 var v = Math.max(0, Math.min(255, Math.round(n)));
334 return (v < 16 ? "0" : "") + v.toString(16);
335 };
336
337 return "#" + byte(color.r) + byte(color.g) + byte(color.b);
338 }
339
340 function darkify_rgb_to_hsl(color) {
341 var r = color.r / 255;
342 var g = color.g / 255;
343 var b = color.b / 255;
344 var max = Math.max(r, g, b);
345 var min = Math.min(r, g, b);
346 var l = (max + min) / 2;
347 var h = 0;
348 var s = 0;
349
350 if (max !== min) {
351 var d = max - min;
352 s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
353 if (max === r) {
354 h = (g - b) / d + (g < b ? 6 : 0);
355 } else if (max === g) {
356 h = (b - r) / d + 2;
357 } else {
358 h = (r - g) / d + 4;
359 }
360 h *= 60;
361 }
362
363 return { h: h, s: s, l: l, a: color.a };
364 }
365
366 /**
367 * How much colour a value actually carries, 0 (grey) to 1 (fully saturated),
368 * measured as the spread between its strongest and weakest channel.
369 *
370 * This exists because HSL saturation cannot be trusted near white or black. Its
371 * denominator (`2 - max - min`) collapses as lightness approaches either end, so
372 * a barely-tinted off-white is divided by almost nothing and comes out looking
373 * saturated: Astra's page background `#f5f7f9` spans 4/255 of the channel range —
374 * grey to any eye — yet scores s = 0.25, well past the brand threshold. Every
375 * theme that ships a tinted off-white (Astra, Kadence, GeneratePress) hit the
376 * same thing, and their section backgrounds were being treated as brand colour,
377 * keeping a hue the user never chose instead of resolving to the preset's
378 * secondary background.
379 *
380 * Chroma has no such denominator, so it reports what the eye sees at any
381 * lightness. A real brand colour still measures high (`#0084d6` → 0.84), so
382 * nothing that should keep its hue loses it.
383 *
384 * @param {{r:number,g:number,b:number}} color
385 * @return {number}
386 */
387 function darkify_chroma(color) {
388 var max = Math.max(color.r, color.g, color.b);
389 var min = Math.min(color.r, color.g, color.b);
390 return (max - min) / 255;
391 }
392
393 /**
394 * Contrast a small control's background must clear against the page backdrop.
395 *
396 * Deliberately low. This is an "is it there at all" floor, not a WCAG text
397 * target: the goal is that a track, handle or stepper reads as an object on the
398 * page, while still sitting quietly behind the content the way it did in light
399 * mode. Pushing it higher makes every slider and divider on the site louder in
400 * dark mode than its designer drew it.
401 */
402 var DARKIFY_CONTROL_MIN_CONTRAST = 1.8;
403
404 /**
405 * Whether an element is small enough that vanishing into the page is a bug
406 * rather than the intended result.
407 *
408 * Two shapes qualify. A hairline — a track, divider, rule or progress bar — is
409 * thin in one axis whatever its length. A control — a slider handle, stepper
410 * button, swatch or badge — is small in both. Anything larger is a surface and
411 * is left to the surface ramp, which is what stops a black hero section or a
412 * dark footer from being lifted into grey.
413 */
414 function darkify_is_control_sized(element) {
415 if (!element || typeof element.getBoundingClientRect !== "function") {
416 return false;
417 }
418
419 var rect = element.getBoundingClientRect();
420 if (!rect.width || !rect.height) {
421 return false;
422 }
423
424 var hairline = rect.height <= 8 || rect.width <= 8;
425 var control = rect.width <= 64 && rect.height <= 64;
426
427 return hairline || control;
428 }
429
430 /**
431 * HSL back to RGB, so a colour this file just derived can be measured.
432 *
433 * @param {{h:number,s:number,l:number}} hsl
434 * @return {{r:number,g:number,b:number}}
435 */
436 function darkify_hsl_to_rgb(hsl) {
437 var h = ((hsl.h % 360) + 360) % 360 / 360;
438 var s = Math.max(0, Math.min(1, hsl.s));
439 var l = Math.max(0, Math.min(1, hsl.l));
440
441 if (s === 0) {
442 var v = Math.round(l * 255);
443 return { r: v, g: v, b: v };
444 }
445
446 var q = l < 0.5 ? l * (1 + s) : l + s - l * s;
447 var p = 2 * l - q;
448 var channel = function (t) {
449 if (t < 0) t += 1;
450 if (t > 1) t -= 1;
451 if (t < 1 / 6) return p + (q - p) * 6 * t;
452 if (t < 1 / 2) return q;
453 if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
454 return p;
455 };
456
457 return {
458 r: Math.round(channel(h + 1 / 3) * 255),
459 g: Math.round(channel(h) * 255),
460 b: Math.round(channel(h - 1 / 3) * 255),
461 };
462 }
463
464 /** WCAG relative luminance. */
465 function darkify_relative_luminance(rgb) {
466 var channel = function (value) {
467 var c = value / 255;
468 return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
469 };
470 return (
471 0.2126 * channel(rgb.r) +
472 0.7152 * channel(rgb.g) +
473 0.0722 * channel(rgb.b)
474 );
475 }
476
477 /** WCAG contrast ratio between two relative luminances. */
478 function darkify_contrast_ratio(a, b) {
479 var lighter = Math.max(a, b);
480 var darker = Math.min(a, b);
481 return (lighter + 0.05) / (darker + 0.05);
482 }
483
484 /**
485 * The first solid colour actually behind `element`, walking up the DOM as it
486 * is live right now — not the page's general dark-mode surface tokens.
487 *
488 * Most of the time these agree: a card gets its background painted dark by
489 * the class-based repaint before its children are visited, so by the time an
490 * icon inside it is processed, reading the parent's live computed background
491 * already returns the dark value. But some containers — a WordPress social
492 * icon link, a badge with its own configured background colour — are never
493 * touched by the class-based repaint at all and keep their light-mode colour
494 * on purpose. An icon inside one of those doesn't sit on the page's dark
495 * surface; it sits on that untouched colour, and judging its contrast against
496 * the wrong backdrop is how a dark icon on a light circle — already perfectly
497 * readable — gets "fixed" into matching its own circle.
498 */
499 function darkify_nearest_opaque_background(element) {
500 var node = element.parentElement;
501 while (node && node.nodeType === 1) {
502 var bg = darkify_parse_color(window.getComputedStyle(node, null).backgroundColor);
503 if (bg && bg.a >= 0.5) {
504 return bg;
505 }
506 node = node.parentElement;
507 }
508 return null;
509 }
510
511 /**
512 * Lift a small control's colour until it can actually be seen.
513 *
514 * The ramps above are built for surfaces, where landing near the page's own
515 * depth is the correct answer — a section that blends into the page is a
516 * section that stopped competing with the content. For a control that same
517 * answer erases it. A WooCommerce price slider is the clearest case: its track
518 * is a 4px `#e5e5e5` line and its handles are solid black, so the ramp sends the
519 * track and the handles to within 0.03 lightness of the page and the whole
520 * control disappears — the user cannot see there is anything to drag. Quantity
521 * steppers, thin dividers, progress bars and toggle tracks all fail the same
522 * way.
523 *
524 * Applied only to small elements, and that limit is what makes it safe: a
525 * genuinely dark *section* must stay dark, and only something control-sized can
526 * be lifted without turning a black hero into a grey one.
527 *
528 * @param {{h:number,s:number,l:number,a:number}} hsl The derived colour.
529 * @param {number} min_ratio Contrast to guarantee against the page backdrop.
530 * @return {{h:number,s:number,l:number,a:number}}
531 */
532 function darkify_ensure_control_visible(hsl, min_ratio) {
533 var tokens = darkify_surface_tokens();
534 if (!tokens) {
535 return hsl;
536 }
537
538 // Measured against the lighter of the two surface tokens: a control sitting on
539 // a raised card is the harder case, and clearing that clears the page too.
540 var backdrop = darkify_relative_luminance(
541 darkify_hsl_to_rgb(tokens.raised.l > tokens.base.l ? tokens.raised : tokens.base),
542 );
543
544 var lifted = { h: hsl.h, s: hsl.s, l: hsl.l, a: hsl.a };
545 // 0.62 keeps the lift below the palette's text level, so a lifted control
546 // never outshines the words next to it.
547 while (lifted.l < 0.62) {
548 var ratio = darkify_contrast_ratio(
549 darkify_relative_luminance(darkify_hsl_to_rgb(lifted)),
550 backdrop,
551 );
552 if (ratio >= min_ratio) {
553 break;
554 }
555 lifted.l += 0.02;
556 }
557
558 return lifted;
559 }
560
561 function darkify_hsl_to_css(hsl) {
562 return (
563 "hsla(" +
564 Math.round(hsl.h) +
565 ", " +
566 Math.round(Math.max(0, Math.min(1, hsl.s)) * 100) +
567 "%, " +
568 Math.round(Math.max(0, Math.min(1, hsl.l)) * 100) +
569 "%, " +
570 Math.round((hsl.a === undefined ? 1 : hsl.a) * 100) / 100 +
571 ")"
572 );
573 }
574
575 /**
576 * The dark-mode counterpart of one light-mode colour, or "" to leave it alone.
577 *
578 * Hue is always preserved and only chroma and lightness move: that is what
579 * separates this from an inversion, which turns a blue accent orange. A colour
580 * near the grey axis is treated as structural and mapped onto the dark
581 * neutral ramp; a saturated one is treated as brand and keeps its identity with
582 * its chroma capped and its lightness pulled to a level that reads on a dark
583 * backdrop instead of blooming against it.
584 *
585 * @param {string} value The light-mode colour, in any parseable form.
586 * @param {string} role text | icon | background | border.
587 * @param {{neutrals?:boolean,control?:boolean}} [opts]
588 * `neutrals` — also transform greys. Off by default, and that default is
589 * load-bearing: greys are what the colour presets are made of, so
590 * re-deriving them here would quietly overrule whichever palette the user
591 * picked and make the preset picker meaningless. Greys belong to the
592 * class-based repaint; this layer only takes them when there is no token
593 * to fall back on (a gradient stop or a generated box's own colour).
594 */
595 function darkify_transform_color(value, role, opts) {
596 opts = opts || {};
597
598 var color = darkify_parse_color(value);
599 if (!color || color.a === 0) {
600 return "";
601 }
602
603 var hsl = darkify_rgb_to_hsl(color);
604 var chroma = darkify_chroma(color);
605 // Either measure may call a colour grey. HSL saturation catches mid-lightness
606 // greys; chroma catches the tinted off-whites and near-blacks whose saturation
607 // HSL inflates (see darkify_chroma). A colour has to look like brand on BOTH
608 // to be treated as brand.
609 var neutral = hsl.s < 0.12 || chroma < 0.06;
610
611 // A tinted near-black is body copy, not a brand colour.
612 //
613 // Themes rarely set text to pure #000: they pick an ink like #0A232A or
614 // #101828 — a hue, but at a lightness no design ever uses for an accent.
615 // HSL reports those at saturation 0.6+ (32 levels of spread over a range that
616 // narrow measures as highly saturated), and chroma of 0.13 clears the grey
617 // test, so the pair above calls the page's own text a brand colour and the
618 // foreground ramp lifts it to a light teal at 62% saturation. Every heading
619 // and paragraph on the site comes out tinted, and the palette's text colour —
620 // which the user actually picked — is overridden by an inline `!important`
621 // that the class-based repaint cannot outrank.
622 //
623 // Lightness is what separates the two cases: an accent has to be visible
624 // against a light page, so it lives in the midtones, while ink and its
625 // near-white counterpart (light text inside a dark section) sit at the ends.
626 // Foreground roles only — a near-black *surface* is structure, and the
627 // surface ramp above already places it. The chroma ceiling keeps a genuinely
628 // saturated deep colour (a dark-red brand rule, say) on the brand path.
629 if (
630 !neutral &&
631 role !== "background" &&
632 chroma < 0.35 &&
633 (hsl.l < 0.22 || hsl.l > 0.92)
634 ) {
635 neutral = true;
636 }
637
638 // Surfaces are placed on the palette ramp unconditionally, ahead of the Brand
639 // Colors setting, because that setting is about brand *hues* and this is not a
640 // hue question — it is the page's structure. Gating it here would mean two
641 // things, both wrong: distinct surfaces would collapse back into one flat
642 // token, and gradients (which recolour regardless) would keep showing the
643 // separation that solid sections had just lost — the same inconsistency
644 // between a gradient section and the solid one under it that started all of
645 // this.
646 if (neutral && role === "background") {
647 return darkify_surface_color(hsl, opts.control);
648 }
649
650 if (neutral) {
651 // Neutral text and borders carry no structural role, so they follow the
652 // palette's own tokens rather than a ramp.
653 if (!opts.neutrals) {
654 return "";
655 }
656 var neutral_tokens = darkify_surface_tokens();
657 var neutral_target =
658 role === "border"
659 ? neutral_tokens && neutral_tokens.border
660 : neutral_tokens && neutral_tokens.text;
661 if (neutral_target) {
662 return darkify_hsl_to_css({
663 h: neutral_target.h,
664 s: neutral_target.s,
665 l: neutral_target.l,
666 a: hsl.a,
667 });
668 }
669 }
670
671 // A brand colour keeps its hue — that is the whole point of adaptive — but its
672 // depth is anchored to the palette rather than to a fixed constant. Without
673 // that anchor a blue panel renders at exactly the same lightness whichever
674 // preset is chosen, so on a light-ish preset it sits *below* the palette's own
675 // surfaces and on a very dark one it floats above them: the brand colour and
676 // the preset visibly belong to different designs. Anchoring means the same
677 // blue reads as the same depth of surface in every preset.
678 var tokens = darkify_surface_tokens();
679 var anchor = function (token, fallback) {
680 return token ? token.l : fallback;
681 };
682
683 if (role === "background") {
684 // Brand surfaces sit at the palette's raised-surface depth, nudged a little
685 // by how light the original was so two brand shades stay distinguishable.
686 var raised = anchor(tokens && tokens.raised, 0.09);
687 hsl.l = Math.max(0.04, raised + (hsl.l - 0.5) * 0.12);
688 hsl.s = Math.min(hsl.s, 0.55);
689 if (opts.control) {
690 hsl = darkify_ensure_control_visible(hsl, DARKIFY_CONTROL_MIN_CONTRAST);
691 }
692 } else if (role === "border") {
693 var line = anchor(tokens && tokens.border, 0.29);
694 hsl.l = Math.max(0.08, line + (hsl.l - 0.5) * 0.1);
695 hsl.s = Math.min(hsl.s, 0.45);
696 } else {
697 // Text and icons share a ramp: both are foreground, both have to stay
698 // legible without going bright enough to bloom. The palette's own text
699 // colour sets the level they aim for.
700 var fg = anchor(tokens && tokens.text, 0.75);
701 hsl.l = Math.max(0.5, Math.min(0.86, fg + (0.5 - hsl.l) * 0.12));
702 hsl.s = Math.min(hsl.s, 0.62);
703 }
704
705 return darkify_hsl_to_css(hsl);
706 }
707
708 /**
709 * The palette's two surface tokens, as HSL.
710 *
711 * Cached against the <html> class list because the theme picker swaps palettes
712 * by changing a class, which changes what these custom properties resolve to.
713 */
714 var darkify_surface_cache = null;
715
716 function darkify_surface_tokens() {
717 var key = document.documentElement.className;
718 if (darkify_surface_cache && darkify_surface_cache.key === key) {
719 return darkify_surface_cache.value;
720 }
721
722 var styles = window.getComputedStyle(document.documentElement);
723 var read = function (name) {
724 var parsed = darkify_parse_color(styles.getPropertyValue(name).trim());
725 return parsed ? darkify_rgb_to_hsl(parsed) : null;
726 };
727
728 var base = read("--darkify_dark_mode_bg");
729 var raised = read("--darkify_dark_mode_secondary_bg");
730 var text = read("--darkify_dark_mode_text_color");
731 var border = read("--darkify_dark_mode_border_color");
732
733 var value =
734 base && raised
735 ? { base: base, raised: raised, text: text, border: border }
736 : null;
737
738 darkify_surface_cache = { key: key, value: value };
739 return value;
740 }
741
742 /**
743 * Place a neutral surface on the palette's ramp instead of flattening it.
744 *
745 * A light design separates its sections by a few percent of lightness — white
746 * against #F5F5F5 — and that difference is the page's structure: it is what
747 * makes a card read as sitting on a page rather than merging into it. Handing
748 * every one of those surfaces to the same token erases the structure, which is
749 * why sections that were distinct in light mode ran together in dark.
750 *
751 * The mapping is inverted, as dark interfaces are built: the *lightest* light
752 * surface becomes the *darkest* dark one, and the subtler alternates lift away
753 * from it. Both ends come from the chosen palette, so a preset still decides
754 * the actual colours — including its hue, which matters for the tinted presets.
755 */
756 /**
757 * Where a surface that was ALREADY dark in the light design belongs.
758 *
759 * The ramp below this exists to convert light surfaces, and it reads from the
760 * band [0.96, 1.0] because that is where light surfaces live — crowded up
761 * against white, a few percent apart. A surface at l = 0.07 is not a member of
762 * that population, and running it through anyway clamps it to the far end and
763 * lands it on the raised token.
764 *
765 * For a section that is the wrong answer twice over. A near-black band on a pale
766 * page is not a background the designer happened to pick; it is a contrast
767 * device — a CTA strip, a dark footer, a highlighted row — whose entire job is
768 * to stand apart from the page around it. Clamping paints it the exact colour of
769 * the palette's own surface, so on a dark page it stops being a band at all and
770 * merges into the section above and below it. The design loses a division it was
771 * built around, and nothing in the output shows why.
772 *
773 * The relation worth keeping is the one the light design stated: this surface is
774 * darker than the page. So it is kept where it is, which preserves that relation
775 * on a dark page too, and only nudged when it would otherwise land on one of the
776 * palette's own surfaces. It nudges downward for the same reason — darker than
777 * the page is what it was, and moving down can never lift a black band into grey.
778 *
779 * Saturation is still capped, so a dark brand band (navy, oxblood) keeps its hue
780 * without the full light-mode chroma blooming on a dark backdrop.
781 */
782 function darkify_preserved_dark_surface(hsl, tokens) {
783 var out = {
784 h: hsl.h,
785 s: Math.min(hsl.s, 0.55),
786 l: hsl.l,
787 a: hsl.a,
788 };
789
790 var lands_on = function (token) {
791 return token && Math.abs(out.l - token.l) < 0.015;
792 };
793
794 if (lands_on(tokens.base) || lands_on(tokens.raised)) {
795 out.l = Math.max(
796 0,
797 Math.min(tokens.base.l, tokens.raised.l) - 0.03,
798 );
799 }
800
801 return out;
802 }
803
804 function darkify_surface_color(hsl, control) {
805 var tokens = darkify_surface_tokens();
806 if (!tokens) {
807 return "";
808 }
809
810 // A surface that arrived dark is not a light surface being converted, and the
811 // ramp below has nothing useful to say about it — see
812 // darkify_preserved_dark_surface(). The boundary is the palette's own line
813 // colour, the lightest tone it still treats as structure rather than content,
814 // so what counts as "already dark" follows whichever preset is in use instead
815 // of a constant that would suit only one of them.
816 //
817 // Controls are excluded and must be: their parts are told apart BY their
818 // greys, and a black slider handle left black is a control the user cannot
819 // see. They keep the second leg further down, which lifts them deliberately.
820 var already_dark = tokens.border ? tokens.border.l : 0.3;
821 if (!control && hsl.l <= already_dark) {
822 return darkify_hsl_to_css(darkify_preserved_dark_surface(hsl, tokens));
823 }
824
825 // Light surfaces live in a narrow band near white, so the band this reads
826 // from is narrow too — otherwise every one of them rounds to the same end.
827 var t = Math.max(0, Math.min(1, (1 - hsl.l) / 0.04));
828 var mix = function (from, to) {
829 return from + (to - from) * t;
830 };
831
832 var out = {
833 h: mix(tokens.base.h, tokens.raised.h),
834 s: mix(tokens.base.s, tokens.raised.s),
835 l: mix(tokens.base.l, tokens.raised.l),
836 a: hsl.a,
837 };
838
839 // Past the band, a surface can only clamp to the raised token — correct for a
840 // section (a page is two or three surfaces deep, and flattening the rest onto
841 // the raised one is what keeps it calm) but wrong for a control, whose parts
842 // are told apart *by* their greys. A price slider is drawn as a light track
843 // with a black filled range; clamped, both land on the raised token, the two
844 // become one colour and the control still reads as a single dead line even
845 // after the visibility floor lifts it.
846 //
847 // So controls get a second leg: below the band their lightness keeps climbing
848 // toward the border token and a little past it, which preserves the ordering
849 // the design drew — the range stays stronger than the track it sits on.
850 //
851 // Controls only, and that restriction is the whole safety argument: run this
852 // on any element and a black hero section or a dark footer would be lifted
853 // into grey, inverting a design that was already dark.
854 if (control && hsl.l < 0.96 && tokens.border) {
855 var line = tokens.border;
856 var depth = Math.max(0, Math.min(1, (0.96 - hsl.l) / 0.36));
857 var beyond = Math.max(0, Math.min(1, (0.6 - hsl.l) / 0.6));
858
859 out.h = line.h;
860 out.s = line.s;
861 out.l =
862 tokens.raised.l +
863 (line.l - tokens.raised.l) * depth +
864 Math.min(0.16, 0.16 * beyond);
865 }
866
867 if (control) {
868 out = darkify_ensure_control_visible(out, DARKIFY_CONTROL_MIN_CONTRAST);
869 }
870
871 return darkify_hsl_to_css(out);
872 }
873
874 /** True while dark mode is on. */
875 function darkify_is_dark() {
876 return document
877 .getElementsByTagName("html")[0]
878 .classList.contains("darkify_dark_mode_enabled");
879 }
880
881 /**
882 * The switcher and anything the user marked `darkify_ignore` are off limits.
883 *
884 * The class-based rules exclude them through `:not()`; an inline declaration
885 * has no such selector to hide behind, so the check has to happen here or this
886 * layer would repaint the toggle it is controlled by.
887 */
888 function darkify_is_excluded_from_adaptation(element) {
889 return (
890 !!element.closest &&
891 !!element.closest(".darkify_switch, .darkify_ignore, .darkify_self_themed")
892 );
893 }
894
895 /** Remember the inline declarations a writer is about to overwrite. */
896 function darkify_store_inline(element, key, props) {
897 if (element.dataset[key]) {
898 return;
899 }
900
901 var saved = {};
902 props.forEach(function (prop) {
903 saved[prop] = element.style.getPropertyValue(prop);
904 });
905 element.dataset[key] = JSON.stringify(saved);
906 }
907
908 /** Put back whatever `darkify_store_inline` saved, then forget it. */
909 function darkify_restore_inline(element, key, props) {
910 var saved = {};
911 if (element.dataset[key]) {
912 try {
913 saved = JSON.parse(element.dataset[key]);
914 } catch (e) {
915 saved = {};
916 }
917 }
918
919 props.forEach(function (prop) {
920 element.style.removeProperty(prop);
921 if (saved[prop]) {
922 element.style.setProperty(prop, saved[prop]);
923 }
924 });
925
926 delete element.dataset[key];
927 }
928
929 /**
930 * Recolour every stop of a gradient, leaving its geometry untouched.
931 *
932 * Greys are included here — unlike the element path — because a gradient has no
933 * token to fall back to: leaving a white stop white would keep a bright band
934 * across the section, which is the whole complaint.
935 */
936 function darkify_recolor_gradient(image) {
937 // Strictly per stop, through the same darkify_transform_color() that solid
938 // backgrounds go through — and that shared path is the point, not an
939 // implementation detail.
940 //
941 // A gradient is very often a fade INTO something: a section whose last stop is
942 // the page's own background colour, so the section dissolves into the page
943 // with no visible edge. Map the stops with one function and the solid page
944 // with another — or collapse the gradient to a single colour because its stops
945 // look close enough — and that last stop stops matching what the page became.
946 // The fade then ends on a colour the page does not have, and the seam the
947 // design was built to avoid appears exactly where the section meets the page.
948 //
949 // Sending both through the same function is what guarantees they still agree:
950 // whatever `#f8f6f3` becomes as a page background, it becomes as a gradient
951 // stop too. Any future "smooth this out" shortcut here has to preserve that
952 // property or it will reintroduce the seam.
953 return image.replace(DARKIFY_COLOR_TOKEN, function (token) {
954 return (
955 darkify_transform_color(token, "background", {
956 force: true,
957 neutrals: true,
958 }) || token
959 );
960 });
961 }
962
963 /* ==========================================================================
964 Deterministic fixes — gradients, shadows, icon colour, colour overrides.
965 --------------------------------------------------------------------------
966 Three things the class-based repaint above cannot reach: a gradient lives
967 in `background-image`, which no `darkify_style_*` rule touches; a coloured
968 shadow was never repainted at all; an SVG icon's `fill`/`stroke` isn't
969 `color`, so it doesn't inherit the text repaint either. Left alone, all
970 three keep their full light-mode brightness on a dark page — a gradient
971 section stays a bright band next to a properly darkened solid one, and a
972 saturated icon or shadow reads as a glow.
973
974 Unlike the adaptive layer this replaced, nothing here classifies a colour
975 as "brand" or preserves its hue: every one of these resolves to a plain
976 preset token, deterministically, same as a solid background already does.
977 The one exception is the override map below, and that is exact-match only
978 — it acts on a colour an admin explicitly pinned, not one this engine
979 decided was worth preserving.
980 ========================================================================== */
981
982 /**
983 * Manual light→dark colour map, keyed by scope.
984 *
985 * Consulted before the deterministic fallback in every writer below, so a
986 * site can pin the handful of colours that need a specific replacement
987 * without changing how anything else on the page is handled.
988 */
989 var darkify_color_override_map = (function () {
990 var rules = [];
991
992 if (
993 typeof darkify_color_overrides === "undefined" ||
994 !darkify_color_overrides
995 ) {
996 return rules;
997 }
998
999 var rows = darkify_color_overrides;
1000 if (typeof rows === "string") {
1001 try {
1002 rows = JSON.parse(rows);
1003 } catch (e) {
1004 return rules;
1005 }
1006 }
1007
1008 if (!Array.isArray(rows)) {
1009 return rules;
1010 }
1011
1012 rows.forEach(function (row) {
1013 if (!row || !row.light_color || !row.dark_color) {
1014 return;
1015 }
1016 var from = darkify_parse_color(row.light_color);
1017 if (!from) {
1018 return;
1019 }
1020 rules.push({
1021 r: from.r,
1022 g: from.g,
1023 b: from.b,
1024 scope: row.override_scope || "all",
1025 to: row.dark_color,
1026 });
1027 });
1028
1029 return rules;
1030 })();
1031
1032 var darkify_has_color_overrides = darkify_color_override_map.length > 0;
1033
1034 /**
1035 * How far a rendered colour may sit from a pinned one and still count as it.
1036 *
1037 * A colour is typed from a design tool, copied from a screenshot, or read
1038 * back from a build pipeline that rounded it — #2154EA against a page
1039 * rendering #2154E9 is one digit out and visually the same colour, but an
1040 * exact match silently does nothing and the setting looks broken. This is a
1041 * squared RGB distance: about five percent per channel, wide enough to
1042 * absorb a slip or a rounding, far too narrow to catch a colour anyone would
1043 * call different.
1044 */
1045 var DARKIFY_OVERRIDE_TOLERANCE = 432;
1046
1047 /** The pinned dark colour for `color` in `role`, or "" if none is close enough. */
1048 function darkify_override_for(color, role) {
1049 var best = "";
1050 var best_distance = DARKIFY_OVERRIDE_TOLERANCE;
1051
1052 for (var i = 0; i < darkify_color_override_map.length; i++) {
1053 var rule = darkify_color_override_map[i];
1054 if (rule.scope !== "all" && rule.scope !== role) {
1055 continue;
1056 }
1057
1058 var dr = rule.r - color.r;
1059 var dg = rule.g - color.g;
1060 var db = rule.b - color.b;
1061 var distance = dr * dr + dg * dg + db * db;
1062
1063 // `<=` so an exact match still wins when the tolerance is set to zero.
1064 if (distance <= best_distance) {
1065 best_distance = distance;
1066 best = rule.to;
1067 }
1068 }
1069
1070 return best;
1071 }
1072
1073 /** Colour properties the override writer may pin, and therefore must undo. */
1074 var DARKIFY_OVERRIDE_PROPS = [
1075 "color",
1076 "background-color",
1077 "border-top-color",
1078 "border-right-color",
1079 "border-bottom-color",
1080 "border-left-color",
1081 ];
1082
1083 var DARKIFY_SHADOW_PROPS = ["box-shadow", "text-shadow"];
1084
1085 /** SVG paint (`fill`/`stroke`) only means something on SVG content. */
1086 function darkify_is_svg_node(element) {
1087 return (
1088 typeof SVGElement !== "undefined" &&
1089 element instanceof SVGElement &&
1090 element.nodeName.toLowerCase() !== "svg"
1091 );
1092 }
1093
1094 /**
1095 * Whether a border side is actually drawn, so pinning a colour to it means
1096 * something. Mirrors the class-based repaint's own guard: WordPress's global
1097 * stylesheet ships `html :where([style*="border-color"]) { border-style:
1098 * solid }`, so writing a colour for a side the design left undrawn makes that
1099 * selector start matching and draws a line that light mode never had.
1100 */
1101 function darkify_border_side_is_drawn(element, prop, computedStyle) {
1102 // "border-top-color" -> "top"
1103 var side = prop.slice(7, -6);
1104 var style = computedStyle || window.getComputedStyle(element, null);
1105
1106 var line = style.getPropertyValue("border-" + side + "-style");
1107 if (!line || line === "none" || line === "hidden") {
1108 return false;
1109 }
1110
1111 return parseFloat(style.getPropertyValue("border-" + side + "-width")) > 0;
1112 }
1113
1114 /**
1115 * Pin an element's own colours to an admin-configured override, if any match.
1116 *
1117 * Gated on `darkify_has_color_overrides` before touching a single element, so
1118 * a site with no overrides configured — the default, and the common case —
1119 * pays no cost here and gets no inline styles from this writer at all.
1120 *
1121 * Matches against `data-darkify-override-src` (captured in
1122 * darkify_process_element_settled(), before the class-based repaint could
1123 * touch this same element) rather than the live computed style: by the time
1124 * this writer runs, `background-color`/`color`/`border-*-color` have usually
1125 * already been repainted to the preset's own tokens, and matching against
1126 * that would compare the engine's own output to the override instead of the
1127 * design's original colour.
1128 */
1129 function darkify_color_override_props(element) {
1130 // darkify_process_overlay_background() already owns background-color for
1131 // elements the class-based repaint never reaches (the same
1132 // `DARKIFY_BG_CLASS_FAMILY` self-gate it uses) — this writer touching the
1133 // same property from a second, independent "previous value" snapshot would
1134 // corrupt the restore chain on the way back to light, since each writer's
1135 // store/restore only knows about its own prior write, not the other one's.
1136 // Excluded from the *stored* prop list too, not just the write, so a stale
1137 // "previous value" the other writer left behind never gets captured here
1138 // in the first place.
1139 var ownsBackground = DARKIFY_BG_CLASS_FAMILY.some(function (cls) {
1140 return element.classList.contains(cls);
1141 });
1142 return ownsBackground
1143 ? DARKIFY_OVERRIDE_PROPS
1144 : DARKIFY_OVERRIDE_PROPS.filter(function (prop) {
1145 return prop !== "background-color";
1146 });
1147 }
1148
1149 function darkify_process_color_overrides(element) {
1150 if (!darkify_has_color_overrides) {
1151 return;
1152 }
1153
1154 var props = darkify_color_override_props(element);
1155
1156 if (darkify_is_dark()) {
1157 if (element.classList.contains("darkify_color_overridden")) {
1158 return;
1159 }
1160
1161 var style = window.getComputedStyle(element, null);
1162 var source = {};
1163 if (element.dataset.darkifyOverrideSrc) {
1164 try {
1165 source = JSON.parse(element.dataset.darkifyOverrideSrc);
1166 } catch (e) {
1167 source = {};
1168 }
1169 }
1170 var next = {};
1171 var changed = false;
1172
1173 props.forEach(function (prop) {
1174 var role =
1175 prop === "background-color"
1176 ? "background"
1177 : prop === "color"
1178 ? "text"
1179 : "border";
1180 if (role === "border" && !darkify_border_side_is_drawn(element, prop, style)) {
1181 return;
1182 }
1183 var raw = source[prop] || style.getPropertyValue(prop);
1184 var color = darkify_parse_color(raw);
1185 if (!color || color.a === 0) {
1186 return;
1187 }
1188 var mapped = darkify_override_for(color, role);
1189 if (mapped) {
1190 next[prop] = mapped;
1191 changed = true;
1192 }
1193 });
1194
1195 if (!changed) {
1196 return;
1197 }
1198
1199 darkify_store_inline(element, "darkifyOverridePrev", props);
1200 element.classList.add("darkify_color_overridden");
1201 Object.keys(next).forEach(function (prop) {
1202 element.style.setProperty(prop, next[prop], "important");
1203 });
1204 } else if (element.classList.contains("darkify_color_overridden")) {
1205 darkify_restore_inline(element, "darkifyOverridePrev", props);
1206 element.classList.remove("darkify_color_overridden");
1207 }
1208 }
1209
1210 /**
1211 * Recolour a gradient onto two fixed preset tones, keeping its own type and
1212 * direction. `image` is a computed `background-image` value — always
1213 * function notation with `rgb()`/`rgba()` colours, since that's what
1214 * getComputedStyle() normalises everything to, which is what makes finding
1215 * the colour stops by regex reliable here.
1216 *
1217 * A stop whose original colour matches a configured colour override (scope
1218 * "background" or "all") is pinned to that override instead of the preset
1219 * token — the same escape hatch a plain background colour gets, just applied
1220 * per stop. Anything left over still falls onto the two-tone ramp: the first
1221 * stop to the base token, every stop after it to the raised token. Stop
1222 * positions (`0%`, `100%`, ...) are dropped rather than carried over, same as
1223 * the fallback below — for the common two-stop case that's invisible, since
1224 * a bare two-stop gradient already defaults to 0%/100%.
1225 *
1226 * Returns null for anything that isn't a plain gradient function (a `url()`
1227 * layer, multiple background layers, `none`), so the caller can fall back.
1228 */
1229 function darkify_preset_gradient(image) {
1230 var fn = image.match(
1231 /^(repeating-linear-gradient|repeating-radial-gradient|repeating-conic-gradient|linear-gradient|radial-gradient|conic-gradient)\(/,
1232 );
1233 if (!fn || image.slice(-1) !== ")") {
1234 return null;
1235 }
1236
1237 var tokens = darkify_surface_tokens();
1238 if (!tokens || !tokens.base || !tokens.raised) {
1239 return null;
1240 }
1241
1242 // Strip the function name and its wrapping parens, then split on the
1243 // top-level commas — reusing the same parenthesis-depth-aware splitter the
1244 // shadow writer below uses, since `rgb(...)` commas are not layer breaks
1245 // here either.
1246 var inner = image.slice(fn[0].length, -1);
1247 var parts = darkify_split_shadow_layers(inner);
1248
1249 // Whatever isn't a colour stop is direction/shape — "90deg", "to right",
1250 // "circle at center" — and gets kept verbatim so the recoloured gradient
1251 // still fades the way the design drew it, just between different colours.
1252 var direction = [];
1253 var colorParts = [];
1254 parts.forEach(function (part) {
1255 DARKIFY_COLOR_TOKEN.lastIndex = 0;
1256 if (DARKIFY_COLOR_TOKEN.test(part)) {
1257 colorParts.push(part);
1258 } else {
1259 direction.push(part.trim());
1260 }
1261 });
1262
1263 var base = darkify_hsl_to_css({
1264 h: tokens.base.h,
1265 s: tokens.base.s,
1266 l: tokens.base.l,
1267 a: 1,
1268 });
1269 var raised = darkify_hsl_to_css({
1270 h: tokens.raised.h,
1271 s: tokens.raised.s,
1272 l: tokens.raised.l,
1273 a: 1,
1274 });
1275
1276 var stops =
1277 colorParts.length >= 2
1278 ? colorParts.map(function (part, index) {
1279 DARKIFY_COLOR_TOKEN.lastIndex = 0;
1280 var match = DARKIFY_COLOR_TOKEN.exec(part);
1281 var color = match ? darkify_parse_color(match[0]) : null;
1282 var mapped =
1283 color && darkify_has_color_overrides
1284 ? darkify_override_for(color, "background")
1285 : "";
1286 return mapped || (index === 0 ? base : raised);
1287 })
1288 : [base, raised];
1289
1290 var head = direction.length ? direction.join(", ") + ", " : "";
1291 return fn[1] + "(" + head + stops.join(", ") + ")";
1292 }
1293
1294 /**
1295 * True when a gradient is a scrim rather than a surface.
1296 *
1297 * A scrim is the fade a design paints OVER something else — a photo, a video,
1298 * a blob-patterned section background — to keep text readable on it:
1299 * `linear-gradient(0deg, rgba(0, 0, 0, 0.35), rgba(0, 0, 0, 0))`. Every one of
1300 * its stops is translucent, because the whole point is that the layer beneath
1301 * shows through. Recolouring it onto two opaque preset tones the way a surface
1302 * gradient is recoloured does not darken that layer, it deletes it: the photo
1303 * or pattern underneath disappears behind a flat dark band.
1304 *
1305 * The test is deliberately strict — every stop must be translucent. One opaque
1306 * stop means the gradient does paint its own surface somewhere, and the preset
1307 * path is right for it.
1308 */
1309 function darkify_is_scrim_gradient(image) {
1310 DARKIFY_COLOR_TOKEN.lastIndex = 0;
1311 var tokens = image.match(DARKIFY_COLOR_TOKEN);
1312 if (!tokens || tokens.length < 2) {
1313 return false;
1314 }
1315
1316 for (var i = 0; i < tokens.length; i++) {
1317 var color = darkify_parse_color(tokens[i]);
1318 if (!color || color.a >= 1) {
1319 return false;
1320 }
1321 }
1322
1323 return true;
1324 }
1325
1326 /**
1327 * Recolour a scrim, keeping every stop's alpha and the gradient's geometry.
1328 *
1329 * A dark scrim is already what dark mode wants — a black fade over a photo
1330 * reads the same on a dark page — so it is returned untouched, and the caller
1331 * treats an unchanged value as "leave this element alone" rather than writing
1332 * an inline copy of what the design already said.
1333 *
1334 * A light scrim (a white fade meant to lift text off a bright photo) is the
1335 * case that has to move: kept as-is it is a bright band across a dark page.
1336 * It is flipped to the dark end of its own hue at the same alpha, so it still
1337 * fades over whatever is beneath instead of covering it.
1338 */
1339 function darkify_scrim_gradient(image) {
1340 return image.replace(DARKIFY_COLOR_TOKEN, function (token) {
1341 var color = darkify_parse_color(token);
1342 if (!color || color.a === 0) {
1343 return token;
1344 }
1345
1346 var hsl = darkify_rgb_to_hsl(color);
1347 if (hsl.l <= 0.5) {
1348 return token;
1349 }
1350
1351 return darkify_hsl_to_css({
1352 h: hsl.h,
1353 s: Math.min(hsl.s, 0.4),
1354 l: Math.max(0.04, Math.min(0.22, 1 - hsl.l)),
1355 a: color.a,
1356 });
1357 });
1358 }
1359
1360 /**
1361 * Recolour a gradient background onto the preset — its own direction and
1362 * shape kept, its colours replaced by two fixed preset tones — so a gradient
1363 * hero and a solid hero read as the same design again instead of one staying
1364 * a bright band next to a properly darkened section. Falls back to a flat
1365 * preset background if the gradient's syntax can't be parsed (a `url()`
1366 * layer mixed in, multiple backgrounds) rather than leaving it untouched.
1367 *
1368 * `data-darkify-gradient-src` is captured once, in
1369 * darkify_process_element_settled(), before the class-based repaint's
1370 * `background` shorthand can reset `background-image` to `none` — without it
1371 * a toggle (which re-visits an already-repainted element) could never recover
1372 * the original gradient to recolour, or tell a handled one apart from a page
1373 * that never had one.
1374 */
1375 function darkify_process_gradient(element) {
1376 if (darkify_is_dark()) {
1377 var source = element.dataset.darkifyGradientSrc;
1378 if (!source) {
1379 return;
1380 }
1381 if (element.classList.contains("darkify_gradient_flattened")) {
1382 return;
1383 }
1384
1385 // A scrim is an overlay over content, not a surface of its own: flattening
1386 // it onto opaque preset tones would hide the photo or pattern it was drawn
1387 // on top of. See darkify_is_scrim_gradient().
1388 if (darkify_is_scrim_gradient(source)) {
1389 var scrim = darkify_scrim_gradient(source);
1390 if (scrim === source) {
1391 return;
1392 }
1393 darkify_store_inline(element, "darkifyGradientPrev", [
1394 "background-image",
1395 "background-color",
1396 ]);
1397 element.classList.add("darkify_gradient_flattened");
1398 element.style.setProperty("background-image", scrim, "important");
1399 return;
1400 }
1401
1402 darkify_store_inline(element, "darkifyGradientPrev", [
1403 "background-image",
1404 "background-color",
1405 ]);
1406 element.classList.add("darkify_gradient_flattened");
1407
1408 var recoloured = darkify_preset_gradient(source);
1409 if (recoloured) {
1410 element.style.setProperty("background-image", recoloured, "important");
1411 return;
1412 }
1413
1414 // Fallback: couldn't parse it as a plain gradient function, so flatten to
1415 // a solid preset background instead of leaving the light-mode gradient in
1416 // place.
1417 element.style.setProperty("background-image", "none", "important");
1418 var tokens = darkify_surface_tokens();
1419 if (tokens && tokens.raised) {
1420 element.style.setProperty(
1421 "background-color",
1422 darkify_hsl_to_css({
1423 h: tokens.raised.h,
1424 s: tokens.raised.s,
1425 l: tokens.raised.l,
1426 a: 1,
1427 }),
1428 "important",
1429 );
1430 }
1431 } else if (element.classList.contains("darkify_gradient_flattened")) {
1432 darkify_restore_inline(element, "darkifyGradientPrev", [
1433 "background-image",
1434 "background-color",
1435 ]);
1436 element.classList.remove("darkify_gradient_flattened");
1437 }
1438 }
1439
1440 /**
1441 * Split a shadow value into its comma-separated layers.
1442 *
1443 * Commas inside `rgb()` / `rgba()` / `color-mix()` are not layer separators,
1444 * so the split has to track parenthesis depth rather than call
1445 * `value.split(",")`.
1446 */
1447 function darkify_split_shadow_layers(value) {
1448 var layers = [];
1449 var depth = 0;
1450 var start = 0;
1451
1452 for (var i = 0; i < value.length; i++) {
1453 var ch = value.charAt(i);
1454 if (ch === "(") {
1455 depth++;
1456 } else if (ch === ")") {
1457 depth--;
1458 } else if (ch === "," && depth === 0) {
1459 layers.push(value.slice(start, i));
1460 start = i + 1;
1461 }
1462 }
1463 layers.push(value.slice(start));
1464
1465 return layers;
1466 }
1467
1468 /**
1469 * Whether one shadow layer is a ring — a hairline drawn with `spread` rather
1470 * than with `border`.
1471 *
1472 * `box-shadow: 0 0 0 1px <colour>` is how modern designs draw a card outline:
1473 * no offset, no blur, a one-pixel spread. Neutralising it to black the same
1474 * way a cast shadow is neutralised would make it vanish — black on
1475 * near-black is nothing at all — so a ring is recoloured with the palette's
1476 * border token instead, recognised geometrically: the two offsets and the
1477 * blur are all zero and the spread is positive.
1478 */
1479 function darkify_shadow_layer_is_ring(layer) {
1480 var lengths = layer
1481 .replace(DARKIFY_COLOR_TOKEN, " ")
1482 .replace(/#[0-9a-f]{3,8}\b/gi, " ")
1483 .replace(/\b(inset|none)\b/gi, " ")
1484 .trim()
1485 .split(/\s+/)
1486 .filter(function (part) {
1487 return part !== "";
1488 });
1489
1490 if (lengths.length !== 4) {
1491 return false;
1492 }
1493
1494 var values = lengths.map(parseFloat);
1495 for (var i = 0; i < values.length; i++) {
1496 if (isNaN(values[i])) {
1497 return false;
1498 }
1499 }
1500
1501 return (
1502 values[0] === 0 && values[1] === 0 && values[2] === 0 && values[3] > 0
1503 );
1504 }
1505
1506 /** Repaint a ring layer with the palette's border colour, same as a real border gets. */
1507 function darkify_recolor_shadow_ring(layer) {
1508 var tokens = darkify_surface_tokens();
1509 if (!tokens || !tokens.border) {
1510 return null;
1511 }
1512
1513 var replaced = false;
1514 var recolored = layer.replace(DARKIFY_COLOR_TOKEN, function (token) {
1515 var color = darkify_parse_color(token);
1516 if (!color || color.a === 0) {
1517 return token;
1518 }
1519 replaced = true;
1520 return darkify_hsl_to_css({
1521 h: tokens.border.h,
1522 s: tokens.border.s,
1523 l: tokens.border.l,
1524 a: 1,
1525 });
1526 });
1527
1528 return replaced ? recolored : null;
1529 }
1530
1531 /** Drop a shadow layer's hue but keep its geometry, so depth survives and glow doesn't. */
1532 function darkify_neutralize_shadow_layer(value, prop) {
1533 if (prop === "box-shadow" && darkify_shadow_layer_is_ring(value)) {
1534 var ring = darkify_recolor_shadow_ring(value);
1535 if (ring) {
1536 return ring;
1537 }
1538 }
1539
1540 return value.replace(DARKIFY_COLOR_TOKEN, function (token) {
1541 var color = darkify_parse_color(token);
1542 if (!color || color.a === 0) {
1543 return token;
1544 }
1545
1546 var mapped = darkify_has_color_overrides
1547 ? darkify_override_for(color, "shadow")
1548 : "";
1549 if (mapped) {
1550 return mapped;
1551 }
1552
1553 // A fully opaque shadow colour is a light-mode choice that would read as
1554 // a solid slab on a dark page, so it drops to a plausible ambient alpha.
1555 var alpha = color.a < 1 ? color.a : 0.45;
1556 return "rgba(0, 0, 0, " + Math.round(alpha * 100) / 100 + ")";
1557 });
1558 }
1559
1560 function darkify_neutralize_shadow(value, prop) {
1561 // Rings only exist on `box-shadow`; a text shadow has no spread to draw one.
1562 if (prop === "box-shadow" && value.indexOf(",") !== -1) {
1563 return darkify_split_shadow_layers(value)
1564 .map(function (layer) {
1565 return darkify_neutralize_shadow_layer(layer, prop);
1566 })
1567 .join(",");
1568 }
1569
1570 return darkify_neutralize_shadow_layer(value, prop);
1571 }
1572
1573 /**
1574 * Take the light-mode colour out of `box-shadow` / `text-shadow`, always — a
1575 * tinted shadow is the one thing that keeps announcing the light design after
1576 * everything else has gone dark, sitting on top of the dimmed surface and
1577 * undoing it. There is no "off" switch: neutralising keeps the shadow's
1578 * geometry, so a card keeps its depth, and only ever drops the colour that
1579 * made it glow.
1580 */
1581 function darkify_process_shadow(element) {
1582 if (darkify_is_dark()) {
1583 if (element.classList.contains("darkify_shadow_neutralized")) {
1584 return;
1585 }
1586
1587 var style = window.getComputedStyle(element, null);
1588 var values = {};
1589 var found = false;
1590
1591 DARKIFY_SHADOW_PROPS.forEach(function (prop) {
1592 var value = style.getPropertyValue(prop);
1593 if (!value || value === "none") {
1594 return;
1595 }
1596 found = true;
1597 values[prop] = darkify_neutralize_shadow(value, prop);
1598 });
1599
1600 if (!found) {
1601 return;
1602 }
1603
1604 darkify_store_inline(element, "darkifyShadowPrev", DARKIFY_SHADOW_PROPS);
1605 element.classList.add("darkify_shadow_neutralized");
1606
1607 Object.keys(values).forEach(function (prop) {
1608 element.style.setProperty(prop, values[prop], "important");
1609 });
1610 } else if (element.classList.contains("darkify_shadow_neutralized")) {
1611 darkify_restore_inline(element, "darkifyShadowPrev", DARKIFY_SHADOW_PROPS);
1612 element.classList.remove("darkify_shadow_neutralized");
1613 }
1614 }
1615
1616 /**
1617 * Give an icon's own colour a dark-mode depth via darkify_transform_color(),
1618 * the same hue-preserving ramp gradients and background-image icons already
1619 * go through — a brand-coloured icon (a gold star rating, a blue logo mark)
1620 * keeps its hue and only has its lightness/chroma pulled to a level that
1621 * reads on a dark backdrop, while a near-black/grey glyph (the common case:
1622 * an SVG `plus`/`minus` or chevron drawn at light-mode ink) is neutral enough
1623 * to land on the palette's text token instead. Forcing every icon onto the
1624 * flat text token regardless of its own hue was tried and reverted: it made
1625 * every accent-coloured icon on the page — star ratings included — read as
1626 * the same shade of grey as body text, which is not what "dark mode" means
1627 * for a deliberately branded colour. `color` is checked on both icon fonts
1628 * (`<i>`) and inline SVG, since an SVG shape without its own `fill` paints
1629 * from `currentColor`; `fill`/`stroke` are only checked where they can mean
1630 * something.
1631 *
1632 * `color` specifically has to be read carefully: by the time this runs, the
1633 * class-based repaint above has already stamped `darkify_style_txt` onto
1634 * this same element and repainted its `color` via CSS — `fill`/`stroke`
1635 * aren't `color`, so the class-based rules never touch them, but `color`
1636 * itself is already the fallback token, not the design's own value. Where
1637 * `data-darkify_preserved_color` exists (it does by default — see
1638 * darkify_process_element_settled()), that's the element's real original
1639 * colour captured before the class-based repaint touched it, and is used
1640 * instead of the live (already-repainted) computed value.
1641 */
1642 function darkify_process_icon_color(element) {
1643 var nodeName = element.nodeName.toLowerCase();
1644 var isSvgPaint = darkify_is_svg_node(element) || nodeName === "svg";
1645 if (!isSvgPaint && nodeName !== "i") {
1646 return;
1647 }
1648
1649 if (darkify_is_dark()) {
1650 if (element.classList.contains("darkify_icon_recoloured")) {
1651 return;
1652 }
1653
1654 var style = window.getComputedStyle(element, null);
1655 var tokens = darkify_surface_tokens();
1656 if (!tokens || !tokens.text) {
1657 return;
1658 }
1659
1660 var props;
1661 if (isSvgPaint && nodeName !== "svg") {
1662 // An inner shape (`<path>`, `<circle>`, ...) almost never states its
1663 // own fill/stroke — it inherits from the `<svg>` root, which is always
1664 // walked first and is what actually carries the icon's colour. Reading
1665 // an inherited fill/stroke here would read whatever the root was just
1666 // correctly recoloured to, then try to match THAT against override
1667 // rules meant for the *original* colour — fails, and silently
1668 // overwrites a correct inherited value with the fallback token. Only a
1669 // shape that states its own fill/stroke gets treated independently.
1670 props = ["color"];
1671 if (element.getAttribute("fill") || element.style.getPropertyValue("fill")) {
1672 props.push("fill");
1673 }
1674 if (element.getAttribute("stroke") || element.style.getPropertyValue("stroke")) {
1675 props.push("stroke");
1676 }
1677 } else {
1678 props = isSvgPaint ? ["color", "fill", "stroke"] : ["color"];
1679 }
1680 var next = {};
1681 var changed = false;
1682 var liveColor = style.getPropertyValue("color");
1683
1684 props.forEach(function (prop) {
1685 // `fill`/`stroke` written as `currentColor` — the normal way an icon
1686 // SVG paints itself — don't carry an independent value at all: reading
1687 // them here returns whatever `color` currently resolves to, live. If
1688 // that's this same already-repainted `color`, this property is a
1689 // second name for the same problem and gets the same fix; if it
1690 // differs, it's a real colour of its own — `fill`/`stroke` are never
1691 // touched by the class-based repaint, so the live read is already the
1692 // design's own value.
1693 var tracksColor = prop !== "color" && style.getPropertyValue(prop) === liveColor;
1694 var raw =
1695 (prop === "color" || tracksColor) && element.dataset.darkify_preserved_color
1696 ? element.dataset.darkify_preserved_color
1697 : style.getPropertyValue(prop);
1698 var color = darkify_parse_color(raw);
1699 if (!color || color.a === 0) {
1700 return;
1701 }
1702
1703 // Judge the icon against what's actually behind it, not the page's
1704 // general dark backdrop — see darkify_nearest_opaque_background(). A
1705 // colour that already reads clearly in its own real context is left
1706 // exactly as designed; recolouring it here would be undoing a contrast
1707 // the design already got right.
1708 var backdrop = darkify_nearest_opaque_background(element);
1709 if (backdrop) {
1710 var ratio = darkify_contrast_ratio(
1711 darkify_relative_luminance(color),
1712 darkify_relative_luminance(backdrop),
1713 );
1714 if (ratio >= DARKIFY_CONTROL_MIN_CONTRAST) {
1715 return;
1716 }
1717 }
1718
1719 var mapped = darkify_has_color_overrides
1720 ? darkify_override_for(color, "icon")
1721 : "";
1722 if (!mapped) {
1723 // neutrals:true because this property already has a concrete colour
1724 // to restore on toggle-back (darkifyIconPrev, below) — unlike the
1725 // gradient-stop/background-image callers of this same function, an
1726 // icon with no colour of its own never reaches this branch, so there
1727 // is no bare palette to protect by leaving greys alone.
1728 mapped = darkify_transform_color(raw, "icon", { neutrals: true });
1729 }
1730 next[prop] =
1731 mapped ||
1732 darkify_hsl_to_css({
1733 h: tokens.text.h,
1734 s: tokens.text.s,
1735 l: tokens.text.l,
1736 a: color.a,
1737 });
1738 changed = true;
1739 });
1740
1741 if (!changed) {
1742 return;
1743 }
1744
1745 darkify_store_inline(element, "darkifyIconPrev", ["color", "fill", "stroke"]);
1746 element.classList.add("darkify_icon_recoloured");
1747 Object.keys(next).forEach(function (prop) {
1748 element.style.setProperty(prop, next[prop], "important");
1749 });
1750 } else if (element.classList.contains("darkify_icon_recoloured")) {
1751 darkify_restore_inline(element, "darkifyIconPrev", ["color", "fill", "stroke"]);
1752 element.classList.remove("darkify_icon_recoloured");
1753 }
1754 }
1755
1756 /** Class-based background repaint's own family — see darkify_process_overlay_background(). */
1757 var DARKIFY_BG_CLASS_FAMILY = [
1758 "darkify_style_all",
1759 "darkify_style_bg_txt",
1760 "darkify_style_bg_border",
1761 "darkify_style_bg",
1762 "darkify_style_secondary_bg",
1763 ];
1764
1765 /**
1766 * Flatten a plain solid background colour for the one class of element the
1767 * class-based repaint deliberately never reaches: a builder's own overlay div
1768 * (Elementor's `.elementor-background-overlay` and similar), sitting on top
1769 * of a background image. Nothing else darkens these — they're excluded from
1770 * the class-based system specifically so the real picture underneath doesn't
1771 * get painted over — so without this they keep their full light-mode colour
1772 * forever, opacity and all.
1773 *
1774 * Self-gating on `DARKIFY_BG_CLASS_FAMILY`: any element the class-based
1775 * repaint already painted a background on is skipped, so this only ever does
1776 * something for the elements nothing else reaches.
1777 *
1778 * That self-gating isn't enough on its own, though: plenty of real, content-
1779 * bearing elements never pick up a `darkify_style_*` class either — a
1780 * countdown timer's number box, a page-builder button — because whatever
1781 * classified the page didn't recognise them as "background+text", not
1782 * because they're meant to be skipped. An Elementor overlay div is always
1783 * empty — its whole job is sitting over a photo, with nothing of its own to
1784 * read — so `textContent` is empty too. A button labelled "Grab The Deal" or
1785 * a box showing "01" is not, and flattening its background without a paired
1786 * foreground adjustment is how a white button with dark text and a dark
1787 * button with dark text end up pixel-for-pixel the same colour: invisible.
1788 * Skipping anything with real text keeps this writer aimed at the empty
1789 * overlay divs it was written for.
1790 */
1791 function darkify_process_overlay_background(element) {
1792 if (
1793 DARKIFY_BG_CLASS_FAMILY.some(function (cls) {
1794 return element.classList.contains(cls);
1795 })
1796 ) {
1797 return;
1798 }
1799
1800 if (element.textContent && element.textContent.trim()) {
1801 return;
1802 }
1803
1804 if (darkify_is_dark()) {
1805 if (element.classList.contains("darkify_overlay_flattened")) {
1806 return;
1807 }
1808
1809 var style = window.getComputedStyle(element, null);
1810 if (style.backgroundImage && style.backgroundImage !== "none") {
1811 // A gradient here belongs to darkify_process_gradient(); a photo is the
1812 // whole reason this element is excluded from the class-based repaint in
1813 // the first place. Either way, this writer stays off `background-image`.
1814 return;
1815 }
1816
1817 var color = darkify_parse_color(style.backgroundColor);
1818 if (!color || color.a === 0) {
1819 return;
1820 }
1821
1822 var target = darkify_has_color_overrides
1823 ? darkify_override_for(color, "background")
1824 : "";
1825 if (!target) {
1826 var tokens = darkify_surface_tokens();
1827 if (!tokens || !tokens.raised) {
1828 return;
1829 }
1830 target = darkify_hsl_to_css({
1831 h: tokens.raised.h,
1832 s: tokens.raised.s,
1833 l: tokens.raised.l,
1834 a: color.a,
1835 });
1836 }
1837
1838 darkify_store_inline(element, "darkifyOverlayPrev", ["background-color"]);
1839 element.classList.add("darkify_overlay_flattened");
1840 element.style.setProperty("background-color", target, "important");
1841 } else if (element.classList.contains("darkify_overlay_flattened")) {
1842 darkify_restore_inline(element, "darkifyOverlayPrev", ["background-color"]);
1843 element.classList.remove("darkify_overlay_flattened");
1844 }
1845 }
1846
1847 /**
1848 * The five writers above, in the order a single element needs them. Wrapped
1849 * so every call site — the main pass, the disallowed-elements branch, and
1850 * the dark/light state sweep — runs them identically.
1851 *
1852 * Excluded elements are checked once here rather than in each writer: the
1853 * switcher and anything marked `darkify_ignore` carry their own colours by
1854 * design, and none of these writers has a selector to hide behind the way the
1855 * class-based rules do.
1856 *
1857 * Marks the element `data-darkify-tracked` so the state sweep's second pass
1858 * can find it again. This can't rely on `.darkify_processed`: a builder's own
1859 * overlay div (`.elementor-background-overlay` and friends) is deliberately
1860 * never marked `darkify_processed` — that's what keeps the class-based
1861 * repaint off it — so without a marker of its own, the *only* time it would
1862 * ever be revisited is the pass that first walked it. Toggle dark mode on a
1863 * page that loaded light and a gradient overlay like this would be painted
1864 * once and never touched again.
1865 */
1866 function darkify_process_deterministic_fixes(element) {
1867 if (darkify_is_excluded_from_adaptation(element)) {
1868 return;
1869 }
1870 if (!element.dataset.darkifyTracked) {
1871 element.dataset.darkifyTracked = "1";
1872 }
1873 darkify_process_gradient(element);
1874 darkify_process_shadow(element);
1875 darkify_process_icon_color(element);
1876 darkify_process_overlay_background(element);
1877 darkify_process_color_overrides(element);
1878 }
1879
1880 darkify_init_keyboard_shortcut_listener();
1881 darkify_init_os_mode_change_listener();
1882
1883 /*
1884 * Every mutation batch used to trigger a full-document walk of its own. While
1885 * the browser is still parsing the page that fires constantly, and each pass
1886 * re-queries the whole document — so the work needed to darken the page was
1887 * competing with the parsing that produces it, and the page stayed visibly
1888 * light for longer the bigger it was. Coalescing to one pass per frame keeps
1889 * every node covered (the walk skips `.darkify_processed`, so a later pass
1890 * picks up whatever the last one missed) at a fraction of the cost.
1891 */
1892 let darkify_walk_scheduled = false;
1893
1894 /* --------------------------------------------------------------------------
1895 Incremental walking.
1896
1897 The childList observer used to answer every DOM mutation with a full
1898 `document.querySelectorAll("*")` walk. The `.darkify_processed` exclusion in
1899 that selector meant each element was only *styled* once, but the query itself
1900 — matching a universal selector against the entire document — was paid in
1901 full on every frame in which anything changed. On pages that mutate
1902 continuously (infinite scroll, carousels, WooCommerce fragment refreshes,
1903 anything React-rendered) that is a whole-document match every 16ms, forever,
1904 and it scales with page size.
1905
1906 Added nodes are the only thing a childList mutation can introduce that needs
1907 processing, so they are collected as roots and only those subtrees are
1908 walked. The first pass is still a full walk — that is the page's initial
1909 state, and there is no subtree to narrow it to.
1910 -------------------------------------------------------------------------- */
1911
1912 /** Subtree roots inserted since the last walk. */
1913 const darkify_pending_roots = new Set();
1914
1915 /** Forces the next walk to cover the whole document (used for the first pass). */
1916 let darkify_full_walk_needed = true;
1917
1918 /**
1919 * Selector for "an element the engine may style", shared by both walk paths.
1920 *
1921 * `html` is in the exclusion list to match the previous behaviour exactly. The
1922 * old selector was written `"* :not(...)"` — with a descendant combinator — so
1923 * it could only ever match elements that have a parent, silently excluding the
1924 * root element. Naming it here keeps that exclusion while letting the selector
1925 * be a plain compound one, which is also what makes it valid to run against a
1926 * subtree root in `darkify_walk_roots()`.
1927 */
1928 const DARKIFY_WALK_SELECTOR =
1929 "*:not(html, head, title, link, meta, script, style, defs, filter, .darkify_processed)";
1930
1931 function darkify_walk_roots(roots) {
1932 darkify_debug_count("incremental_walks");
1933 darkify_debug_time("walk_ms", function () {
1934 darkify_suspend_class_watch(function () {
1935 roots.forEach(function (root) {
1936 if (!root.isConnected) {
1937 return;
1938 }
1939 // The root itself is a new node too, not just its descendants —
1940 // `querySelectorAll` below only reaches its descendants.
1941 //
1942 // Matched with a plain `matches()` guarded by try/catch rather than the
1943 // engine's pseudo-element-stripping helper: DARKIFY_WALK_SELECTOR is a
1944 // fixed compound selector with no pseudo-elements in it, so there is
1945 // nothing to strip, and the helper does not exist in every build of this
1946 // engine.
1947 if (root.nodeType === 1 && !root.classList.contains("darkify_processed")) {
1948 var walkable = false;
1949 try {
1950 walkable = root.matches(DARKIFY_WALK_SELECTOR);
1951 } catch (e) {
1952 walkable = false;
1953 }
1954 if (walkable) {
1955 darkify_process_element(root);
1956 }
1957 }
1958 if (root.querySelectorAll) {
1959 root.querySelectorAll(DARKIFY_WALK_SELECTOR).forEach(function (element) {
1960 darkify_process_element(element);
1961 });
1962 }
1963
1964 // A subtree can be detached and later re-inserted wholesale — a chat
1965 // widget reopening its own greeting card, a cookie banner re-showing
1966 // itself — which fires the same "added" mutation a brand new node
1967 // would. `.darkify_processed` above skips it on the assumption that
1968 // already-processed means already-correct, which class-based styling
1969 // guarantees (it only ever applies under `.darkify_dark_mode_enabled`)
1970 // but these writers' inline `!important` overrides do not: those are
1971 // written once and only undone by the state sweep that follows a
1972 // dark<->light toggle, so a node detached at that exact moment keeps
1973 // whichever mode's styling it last had, indefinitely, once reattached.
1974 // Re-running the same idempotent writers the sweep uses catches it the
1975 // moment it reappears instead.
1976 darkify_resync_reattached_deterministic_fixes(root);
1977 });
1978 });
1979 });
1980
1981 darkify_finish_painting();
1982 }
1983
1984 /** Selector for anything the deterministic writers have touched inline. */
1985 const DARKIFY_DETERMINISTIC_MARKER_SELECTOR =
1986 ".darkify_gradient_flattened, .darkify_shadow_neutralized, .darkify_icon_recoloured, .darkify_overlay_flattened, .darkify_color_overridden";
1987
1988 function darkify_resync_reattached_deterministic_fixes(root) {
1989 if (root.nodeType !== 1) {
1990 return;
1991 }
1992 var marked = false;
1993 try {
1994 marked = root.matches(DARKIFY_DETERMINISTIC_MARKER_SELECTOR);
1995 } catch (e) {
1996 marked = false;
1997 }
1998 if (marked) {
1999 darkify_process_deterministic_fixes(root);
2000 }
2001 if (root.querySelectorAll) {
2002 root
2003 .querySelectorAll(DARKIFY_DETERMINISTIC_MARKER_SELECTOR)
2004 .forEach(function (element) {
2005 darkify_process_deterministic_fixes(element);
2006 });
2007 }
2008 }
2009
2010 function darkify_schedule_walk() {
2011 if (darkify_walk_scheduled) {
2012 return;
2013 }
2014 darkify_walk_scheduled = true;
2015
2016 var run = function () {
2017 darkify_walk_scheduled = false;
2018
2019 if (darkify_full_walk_needed) {
2020 darkify_full_walk_needed = false;
2021 darkify_pending_roots.clear();
2022 darkify_init_processes();
2023 } else if (darkify_pending_roots.size > 0) {
2024 var roots = Array.from(darkify_pending_roots);
2025 darkify_pending_roots.clear();
2026 darkify_walk_roots(roots);
2027 }
2028
2029 darkify_process_iframes(); // �
2030 darkify proceed iframe
2031 };
2032
2033 if (typeof requestAnimationFrame === "function") {
2034 requestAnimationFrame(run);
2035 } else {
2036 setTimeout(run, 0);
2037 }
2038 }
2039
2040 const darkify_observer = new MutationObserver(function (mutationsList) {
2041 darkify_debug_count("observer_callbacks");
2042 var has_added = false;
2043
2044 for (var i = 0; i < mutationsList.length; i++) {
2045 var added = mutationsList[i].addedNodes;
2046 for (var j = 0; j < added.length; j++) {
2047 var node = added[j];
2048 if (node.nodeType !== 1) {
2049 continue;
2050 }
2051 // Engine-generated nodes carry no design of their own to read, and
2052 // queueing them here is how an observer ends up feeding itself.
2053 if (
2054 node.classList &&
2055 (node.classList.contains("darkify_switch") ||
2056 node.classList.contains("darkify_ignore"))
2057 ) {
2058 continue;
2059 }
2060 if (!darkify_full_walk_needed) {
2061 darkify_pending_roots.add(node);
2062 darkify_debug_count("nodes_queued");
2063 }
2064 has_added = true;
2065 }
2066 }
2067
2068 if (has_added || darkify_full_walk_needed) {
2069 darkify_schedule_walk();
2070 }
2071 });
2072
2073 /* ==========================================================================
2074 Class-change watching — one delegated observer for the whole document
2075 --------------------------------------------------------------------------
2076 A processed element whose class list changes may have been re-styled by the
2077 theme (`.is-active`, `.is-open`, a builder's scroll state), so it has to be
2078 re-classified against its new colours. That requirement is unchanged; how it
2079 is watched is what changed here.
2080
2081 The previous implementation registered this observer on every element
2082 individually, and — because a re-process necessarily writes classes, which
2083 wakes the observer again — it defended itself by disconnecting and then
2084 re-registering across `document.querySelectorAll("*")` inside its own
2085 callback. Every single class mutation anywhere on the page therefore cost one
2086 full-document query plus one `observe()` call per element, and each pass
2087 wrote ~16 classes per element, so the registrations multiplied against each
2088 other. Measured on a 2,929-element page: 1.23 million `observe()` calls
2089 during load and 6.31 million on a single dark-mode toggle. That is the freeze
2090 the reviews describe; it grows with the square of the page size, which is why
2091 it is survivable on a small theme demo and fatal on a real builder page.
2092
2093 A subtree observer on `document` sees exactly the same mutations for one
2094 `observe()` call total, and never needs re-registering as the DOM changes —
2095 new nodes are covered the moment they are inserted. Self-inflicted mutations
2096 are suppressed by depth counter rather than by disconnecting, so no external
2097 mutation can slip through a window where the observer was off. Targets are
2098 coalesced into a Set and drained once per frame, so an element whose classes
2099 change ten times in one frame is re-processed once.
2100 ========================================================================== */
2101
2102 /** Depth of the current engine-owned write. >0 means "ignore what we see". */
2103 let darkify_class_watch_depth = 0;
2104
2105 /** Targets awaiting re-classification, deduplicated. */
2106 const darkify_class_dirty = new Set();
2107 let darkify_class_drain_scheduled = false;
2108
2109 /**
2110 * Run `fn` with class-change watching suppressed.
2111 *
2112 * The engine stamps `darkify_style_*`, `darkify_processed` and friends as its
2113 * normal operation. Those writes must not feed back into the watcher, or every
2114 * pass would re-queue everything it just touched and never settle. Records
2115 * accumulated during the write are dropped on the way out — `takeRecords()`
2116 * empties the queue without invoking the callback — so only genuinely external
2117 * changes survive to be acted on.
2118 *
2119 * `whole_document` decides how far the transition suppression that comes with a
2120 * pass reaches, and it is off by default. Document-wide suppression cancels
2121 * every transition running anywhere on the page, so it may only be used by the
2122 * two passes that genuinely read the whole document — the first walk and the
2123 * dark-mode state sweep — where nothing else is animating anyway. The
2124 * incremental passes run constantly (a menu opening, a slider advancing, any
2125 * theme class flip wakes them), and suppressing document-wide for those killed
2126 * animations on elements the pass never even looked at: the hover effect two
2127 * sections away snapped instead of easing. Those passes suppress per element
2128 * instead, in darkify_process_element().
2129 */
2130 let darkify_pass_is_whole_document = false;
2131
2132 function darkify_suspend_class_watch(fn, whole_document) {
2133 // Entering the outermost engine write is also the start of a "pass", so this
2134 // is where transitions are suppressed for its duration — see
2135 // darkify_begin_pass() for why that is done once here rather than per element.
2136 let owns_pass = false;
2137 if (darkify_class_watch_depth === 0) {
2138 darkify_pass_is_whole_document = whole_document === true;
2139 if (darkify_pass_is_whole_document) {
2140 owns_pass = true;
2141 darkify_begin_pass();
2142 }
2143 }
2144 darkify_class_watch_depth++;
2145 try {
2146 return fn();
2147 } finally {
2148 darkify_class_watch_depth--;
2149 if (darkify_class_watch_depth === 0) {
2150 elements_class_changed.takeRecords();
2151 if (owns_pass) {
2152 darkify_end_pass();
2153 }
2154 darkify_pass_is_whole_document = false;
2155 }
2156 }
2157 }
2158
2159 function darkify_drain_class_changes() {
2160 darkify_class_drain_scheduled = false;
2161 darkify_debug_count("class_redrives");
2162
2163 if (darkify_class_dirty.size === 0) {
2164 return;
2165 }
2166
2167 const targets = Array.from(darkify_class_dirty);
2168 darkify_class_dirty.clear();
2169
2170 darkify_suspend_class_watch(function () {
2171 for (let i = 0; i < targets.length; i++) {
2172 const target = targets[i];
2173
2174 // Dropped from the document between queueing and draining.
2175 if (!target.isConnected) {
2176 continue;
2177 }
2178
2179 // Re-checked at drain time, not at queue time: a class may have been
2180 // added and removed again within the same frame, leaving the element
2181 // exactly as the last pass classified it. Comparing the settled value
2182 // skips the re-process entirely in that case.
2183 const now = target.classList.toString();
2184 if (target.dataset.darkify_preserved_classes === now) {
2185 continue;
2186 }
2187
2188 target.classList.remove("darkify_processed");
2189 darkify_debug_count("elements_reprocessed");
2190 darkify_process_element(target);
2191 target.dataset.darkify_preserved_classes = target.classList.toString();
2192 }
2193 });
2194 }
2195
2196 const elements_class_changed = new MutationObserver((mutationsList) => {
2197 if (darkify_class_watch_depth > 0 || document.readyState === "loading") {
2198 return;
2199 }
2200
2201 for (let i = 0; i < mutationsList.length; i++) {
2202 const target = mutationsList[i].target;
2203
2204 if (
2205 target.nodeType !== 1 ||
2206 !target.classList.contains("darkify_processed")
2207 ) {
2208 continue;
2209 }
2210
2211 // Cheap reject before taking on any work: an element whose settled class
2212 // string still matches what the last pass recorded has nothing to
2213 // re-classify. This is the common case during hover and scroll churn.
2214 if (target.dataset.darkify_preserved_classes === target.classList.toString()) {
2215 continue;
2216 }
2217
2218 darkify_class_dirty.add(target);
2219 }
2220
2221 if (darkify_class_dirty.size > 0 && !darkify_class_drain_scheduled) {
2222 darkify_class_drain_scheduled = true;
2223 if (typeof requestAnimationFrame === "function") {
2224 requestAnimationFrame(darkify_drain_class_changes);
2225 } else {
2226 setTimeout(darkify_drain_class_changes, 0);
2227 }
2228 }
2229 });
2230
2231 /**
2232 * Re-run the state-dependent writers across the page after dark mode flips.
2233 *
2234 * Everything the engine does through classes flips for free — the rules are all
2235 * scoped under `.darkify_dark_mode_enabled`, so the cascade does the work. What
2236 * this sweep exists for is the writers that produce *inline* declarations
2237 * (the adaptive colour layer, image filters, the background-image darkener,
2238 * translucency): those aren't gated on the state class and have to be applied
2239 * and undone by hand as it changes.
2240 *
2241 * Three things changed here, none of them to what the sweep does:
2242 *
2243 * * it selects `.darkify_processed` directly instead of walking every element
2244 * in the document and testing each one for that class;
2245 * * it reads each element's computed style once and passes it down, rather
2246 * than calling `getComputedStyle` twice per element for the icon check;
2247 * * it is called once per settled state change rather than once per mutation
2248 * record (see the observer below).
2249 */
2250 /**
2251 * Repaint everything a palette change touches, without reloading the page.
2252 *
2253 * Changing a preset rewrites the `--darkify_dark_mode_*` variables, and every
2254 * rule written in terms of them follows instantly. Two groups do not:
2255 *
2256 * The state sweep's writers — image brightness, icon backgrounds, inline SVG —
2257 * which set inline values that contain no variable to re-resolve.
2258 *
2259 * The deterministic writers — gradients, shadows, overlays — which additionally
2260 * refuse to run twice. darkify_process_gradient() returns immediately when it
2261 * finds its own `darkify_gradient_flattened` marker, so a second pass changes
2262 * nothing at all. That guard is right during a normal walk, where re-running
2263 * would read an already-flattened background as if it were the source; it is
2264 * wrong here, where the source is exactly what we want re-read against new
2265 * colours. Clearing the markers first is what lets those writers work from
2266 * `dataset.darkifyGradientSrc` again, and darkify_store_inline() already
2267 * refuses to overwrite a saved original, so the light-mode restore stays
2268 * intact.
2269 *
2270 * Called by the Live Preview when a preset or an individual colour changes. It
2271 * is why switching preset used to leave a hero gradient on the previous
2272 * palette's colours until the frame was reloaded.
2273 */
2274 function darkify_repaint_palette() {
2275 if (!darkify_is_dark()) {
2276 return;
2277 }
2278
2279 var markers = [
2280 "darkify_gradient_flattened",
2281 "darkify_shadow_neutralized",
2282 "darkify_icon_recoloured",
2283 "darkify_overlay_flattened",
2284 "darkify_color_overridden",
2285 ];
2286
2287 darkify_suspend_class_watch(function () {
2288 document
2289 .querySelectorAll(DARKIFY_DETERMINISTIC_MARKER_SELECTOR)
2290 .forEach(function (element) {
2291 for (var i = 0; i < markers.length; i++) {
2292 element.classList.remove(markers[i]);
2293 }
2294 darkify_process_deterministic_fixes(element);
2295 });
2296 });
2297
2298 darkify_state_sweep();
2299 }
2300
2301 function darkify_state_sweep() {
2302 darkify_debug_count("state_sweeps");
2303 darkify_debug_time("sweep_ms", function () {
2304 darkify_suspend_class_watch(function () {
2305 {
2306 document
2307 .querySelectorAll(".darkify_processed")
2308 .forEach((element) => {
2309 {
2310 if (
2311 darkify_disallowed_elements.length > 0 &&
2312 element.matches(darkify_disallowed_elements)
2313 ) {
2314 return;
2315 }
2316 // Mirrors the branch in darkify_process_element(): an inline SVG
2317 // is an icon and must not be washed. Without this the sweep undoes
2318 // the classification's decision — it re-runs the darkener on a
2319 // chevron, and even where the recoloured icon still wins on
2320 // `background-image`, the `background-size: 100% 100%` the wash
2321 // brings with it stays behind and stretches the glyph across the
2322 // whole control.
2323 var darkify_sweep_style = window.getComputedStyle(element, null);
2324 var darkify_sweep_bg = darkify_sweep_style.backgroundImage;
2325 if (
2326 darkify_sweep_bg &&
2327 darkify_sweep_bg.indexOf("data:image/svg+xml") !== -1
2328 ) {
2329 darkify_process_icon_background(element, darkify_sweep_style);
2330 } else if (darkify_enable_bg_image_darken === "1") {
2331 darkify_darken_bg_image(element, darken_level);
2332 }
2333 if (
2334 (darkify_enable_low_image_brightness === "1" ||
2335 darkify_enable_image_grayscale === "1") &&
2336 element.nodeName.toLowerCase() === "img"
2337 ) {
2338 darkify_img_brightness_and_grayscale(element);
2339 }
2340 if (
2341 darkify_enable_invert_inline_svg === "1" &&
2342 element.nodeName.toLowerCase() === "svg"
2343 ) {
2344 darkify_invert_inline_svg(element);
2345 }
2346 if (
2347 darkify_enable_low_video_brightness === "1" ||
2348 darkify_enable_video_grayscale === "1"
2349 ) {
2350 if (element.nodeName.toLowerCase() === "video") {
2351 darkify_video_brightness_and_grayscale(element);
2352 }
2353 if (
2354 element.nodeName.toLowerCase() === "iframe" &&
2355 element.getAttribute("src") != null
2356 ) {
2357 const srcAttribute = element.getAttribute("src");
2358 if (
2359 srcAttribute.includes("youtube") ||
2360 srcAttribute.includes("vimeo") ||
2361 srcAttribute.includes("dailymotion")
2362 ) {
2363 darkify_video_brightness_and_grayscale(element);
2364 }
2365 }
2366 }
2367 if (element.hasAttribute("data-darkify_alpha_bg")) {
2368 darkify_fix_background_color_alpha(element);
2369 }
2370 // Unlike the class-based rules, these writers' inline
2371 // declarations aren't gated on `darkify_dark_mode_enabled` — they
2372 // have to be applied and undone by hand as the state flips.
2373 darkify_process_deterministic_fixes(element);
2374 }
2375 });
2376
2377 // Second pass for anything the writers above touched that the loop
2378 // over `.darkify_processed` cannot see, because it never became
2379 // `darkify_processed` — builder overlays, most of all. Re-visiting an
2380 // element already handled above is harmless: every writer guards on
2381 // its own marker.
2382 document
2383 .querySelectorAll("[data-darkify-tracked]")
2384 .forEach(function (element) {
2385 darkify_process_deterministic_fixes(element);
2386 });
2387 }
2388 }, true);
2389 });
2390 }
2391
2392 /* --------------------------------------------------------------------------
2393 The state observer itself.
2394
2395 Previously this watched `<html>` with a bare `{ attributes: true }` and ran
2396 the full sweep once per mutation record. Neither part was safe on a real
2397 site. Unfiltered means every attribute anything writes to `<html>` wakes it —
2398 and `<html>` is the busiest element on a modern page: scroll-lock libraries,
2399 smooth-scroll scripts, builders and cookie banners all write `style` or
2400 toggle helper classes there, sometimes per scroll frame. Per-record means a
2401 script that flips two classes in one tick paid for two full sweeps.
2402
2403 Now: the filter narrows it to `class`, and the dark state is compared against
2404 the last one acted on, so the sweep runs only when dark mode has genuinely
2405 changed — a theme adding `.scrolled` to `<html>` costs one string comparison
2406 instead of a full-page repaint. Coalescing onto a frame collapses a burst of
2407 class writes into a single sweep.
2408 -------------------------------------------------------------------------- */
2409
2410 let darkify_last_swept_state = null;
2411 let darkify_state_sweep_scheduled = false;
2412
2413 const dark_mode_status_changed = new MutationObserver(() => {
2414 const is_dark = document.documentElement.classList.contains(
2415 "darkify_dark_mode_enabled",
2416 );
2417
2418 if (is_dark === darkify_last_swept_state) {
2419 return;
2420 }
2421 darkify_last_swept_state = is_dark;
2422
2423 if (darkify_state_sweep_scheduled) {
2424 return;
2425 }
2426 darkify_state_sweep_scheduled = true;
2427
2428 const run = function () {
2429 darkify_state_sweep_scheduled = false;
2430 darkify_state_sweep();
2431 };
2432
2433 if (typeof requestAnimationFrame === "function") {
2434 requestAnimationFrame(run);
2435 } else {
2436 setTimeout(run, 0);
2437 }
2438 });
2439
2440 function darkify_change_state() {
2441 if (darkify_is_this_admin_panel === "1") {
2442 localStorage.darkify_admin_panel_last_state = document
2443 .getElementsByTagName("html")[0]
2444 .classList.contains("darkify_dark_mode_enabled")
2445 ? "1"
2446 : "0";
2447 } else {
2448 localStorage.darkify_last_state = document
2449 .getElementsByTagName("html")[0]
2450 .classList.contains("darkify_dark_mode_enabled")
2451 ? "1"
2452 : "0";
2453 }
2454 }
2455
2456 function darkify_switch_trigger() {
2457 if (!has_process_run_at_least_once) {
2458 darkify_init_processes();
2459 darkify_init_observer();
2460 }
2461
2462 const htmlElement = document.getElementsByTagName("html")[0];
2463
2464 if (htmlElement.classList.contains("darkify_dark_mode_enabled")) {
2465 htmlElement.classList.remove("darkify_dark_mode_enabled");
2466 } else {
2467 htmlElement.classList.add("darkify_dark_mode_enabled");
2468 }
2469
2470 darkify_change_state();
2471
2472 darkify_process_iframes(); // �
2473 darkify proceed iframe
2474 }
2475
2476 function darkify_theme_select(theme) {
2477 if (!darkify_is_block_editor_context()) return;
2478 if (!has_process_run_at_least_once) {
2479 darkify_init_processes();
2480 darkify_init_observer();
2481 }
2482
2483 const htmlElement = document.documentElement;
2484
2485 // �
2486 Remove all previous theme classes (IMPORTANT)
2487 htmlElement.classList.forEach((cls) => {
2488 if (cls.startsWith("darkify-")) {
2489 htmlElement.classList.remove(cls);
2490 }
2491 });
2492
2493 // �
2494 Save in localStorage
2495 localStorage.darkify_selected_theme = theme;
2496
2497 // �
2498 Update all select dropdowns
2499 darkify_update_theme_selectors(theme);
2500
2501 if (darkify_is_this_admin_panel === "1") {
2502 if (localStorage.darkify_admin_panel_last_state === "1") {
2503 htmlElement.classList.add("darkify_dark_mode_enabled");
2504 htmlElement.classList.add("darkify-" + theme);
2505 } else {
2506 htmlElement.classList.remove("darkify_dark_mode_enabled");
2507 }
2508 }
2509
2510 darkify_change_state();
2511
2512 // Apply the new palette to the parent first, then sync iframes so they pick
2513 // up the updated variables rather than the previous palette.
2514 darkify_apply_palette(theme);
2515
2516 darkify_process_iframes();
2517 }
2518
2519 document.addEventListener("DOMContentLoaded", function () {
2520 if (!darkify_is_block_editor_context()) return;
2521
2522 let theme = localStorage.getItem("darkify_selected_theme") || "set1";
2523
2524 darkify_theme_select(theme);
2525 });
2526
2527 function darkify_update_theme_selectors(theme) {
2528 document
2529 .querySelectorAll(".darkify-theme-selector")
2530 .forEach(function (select) {
2531 if (select.value !== theme) {
2532 select.value = theme;
2533 }
2534 });
2535 }
2536
2537 function darkify_is_block_editor_context() {
2538 return (
2539 typeof document !== "undefined" &&
2540 document.body &&
2541 (document.body.classList.contains("block-editor-page") ||
2542 document.querySelector(".edit-post-visual-editor") !== null ||
2543 document.querySelector(".block-editor") !== null)
2544 );
2545 }
2546
2547 function darkify_restore_selected_theme() {
2548 if (!darkify_is_block_editor_context()) return;
2549 const storedTheme = localStorage.darkify_selected_theme;
2550
2551 if (!storedTheme) return;
2552
2553 darkify_update_theme_selectors(storedTheme);
2554 darkify_theme_select(storedTheme);
2555 }
2556
2557 function darkify_apply_palette(theme) {
2558 if (!darkify_is_block_editor_context()) return;
2559 const palettes = {
2560 set1: {
2561 bg: "#0F0F0F",
2562 secondary_bg: "#171717",
2563 text_color: "#BEBEBE",
2564 link_color: "#E7E7E7",
2565 link_hover_color: "#BEBEBE",
2566 input_bg: "#2D2D2D",
2567 input_text_color: "#BEBEBE",
2568 input_placeholder_color: "#BEBEBE",
2569 border_color: "#4A4A4A",
2570 btn_text_color: "#BEBEBE",
2571 btn_bg: "#4A4A4A",
2572 btn_text_hover_color: "#BEBEBE",
2573 btn_hover_bg: "#2D2D2D",
2574 btn_border_color: "#4A4A4A",
2575 btn_hover_border_color: "#2D2D2D",
2576 },
2577 set3: {
2578 bg: "#211e3c",
2579 secondary_bg: "#302C57",
2580 text_color: "#B1BBD8",
2581 link_color: "#8071fb",
2582 link_hover_color: "#B1BBD8",
2583 input_bg: "#2A264D",
2584 input_text_color: "#B1BBD8",
2585 input_placeholder_color: "#B1BBD8",
2586 border_color: "#4E478D",
2587 btn_text_color: "#B1BBD8",
2588 btn_bg: "#4E478D",
2589 btn_text_hover_color: "#B1BBD8",
2590 btn_hover_bg: "#2A264D",
2591 btn_border_color: "#4E478D",
2592 btn_hover_border_color: "#2A264D",
2593 },
2594
2595 set6: {
2596 bg: "#082032",
2597 secondary_bg: "#061825",
2598 text_color: "#B5D9F3",
2599 link_color: "#61bbff",
2600 link_hover_color: "#B5D9F3",
2601 input_bg: "#0E3755",
2602 input_text_color: "#B5D9F3",
2603 input_placeholder_color: "#B5D9F3",
2604 border_color: "#144E78",
2605 btn_text_color: "#B5D9F3",
2606 btn_bg: "#144E78",
2607 btn_text_hover_color: "#B5D9F3",
2608 btn_hover_bg: "#0E3755",
2609 btn_border_color: "#144E78",
2610 btn_hover_border_color: "#0E3755",
2611 },
2612
2613 set9: {
2614 bg: "#04261d",
2615 secondary_bg: "#021e16",
2616 text_color: "#C1D2BB",
2617 link_color: "#00d29a",
2618 link_hover_color: "#C1D2BB",
2619 input_bg: "#073d2f",
2620 input_text_color: "#C1D2BB",
2621 input_placeholder_color: "#C1D2BB",
2622 border_color: "#095541",
2623 btn_text_color: "#C1D2BB",
2624 btn_bg: "#095541",
2625 btn_text_hover_color: "#C1D2BB",
2626 btn_hover_bg: "#073d2f",
2627 btn_border_color: "#095541",
2628 btn_hover_border_color: "#073d2f",
2629 },
2630
2631 set10: {
2632 bg: "#171004",
2633 secondary_bg: "#211706",
2634 text_color: "#E0D2BD",
2635 link_color: "#e09525",
2636 link_hover_color: "#E0D2BD",
2637 input_bg: "#372911",
2638 input_text_color: "#E0D2BD",
2639 input_placeholder_color: "#E0D2BD",
2640 border_color: "#5D4010",
2641 btn_text_color: "#E0D2BD",
2642 btn_bg: "#5D4010",
2643 btn_text_hover_color: "#E0D2BD",
2644 btn_hover_bg: "#372911",
2645 btn_border_color: "#5D4010",
2646 btn_hover_border_color: "#372911",
2647 },
2648 };
2649
2650 const palette = palettes[theme] || palettes["set1"];
2651
2652 document.documentElement.style.setProperty(
2653 "--darkify_dark_mode_bg",
2654 palette.bg,
2655 );
2656 document.documentElement.style.setProperty(
2657 "--darkify_dark_mode_secondary_bg",
2658 palette.secondary_bg,
2659 );
2660 document.documentElement.style.setProperty(
2661 "--darkify_dark_mode_text_color",
2662 palette.text_color,
2663 );
2664 document.documentElement.style.setProperty(
2665 "--darkify_dark_mode_link_color",
2666 palette.link_color,
2667 );
2668 document.documentElement.style.setProperty(
2669 "--darkify_dark_mode_link_hover_color",
2670 palette.link_hover_color,
2671 );
2672 document.documentElement.style.setProperty(
2673 "--darkify_dark_mode_input_bg",
2674 palette.input_bg,
2675 );
2676 document.documentElement.style.setProperty(
2677 "--darkify_dark_mode_input_text_color",
2678 palette.input_text_color,
2679 );
2680 document.documentElement.style.setProperty(
2681 "--darkify_dark_mode_input_placeholder_color",
2682 palette.input_placeholder_color,
2683 );
2684 document.documentElement.style.setProperty(
2685 "--darkify_dark_mode_border_color",
2686 palette.border_color,
2687 );
2688 document.documentElement.style.setProperty(
2689 "--darkify_dark_mode_btn_bg",
2690 palette.btn_bg,
2691 );
2692 document.documentElement.style.setProperty(
2693 "--darkify_dark_mode_btn_text_color",
2694 palette.btn_text_color,
2695 );
2696 document.documentElement.style.setProperty(
2697 "--darkify_dark_mode_btn_hover_bg",
2698 palette.btn_hover_bg,
2699 );
2700 document.documentElement.style.setProperty(
2701 "--darkify_dark_mode_btn_text_hover_color",
2702 palette.btn_text_hover_color,
2703 );
2704 document.documentElement.style.setProperty(
2705 "--darkify_dark_mode_btn_border_color",
2706 palette.btn_border_color,
2707 );
2708 document.documentElement.style.setProperty(
2709 "--darkify_dark_mode_btn_hover_border_color",
2710 palette.btn_hover_border_color,
2711 );
2712 }
2713
2714 // ---------------------------------------------------------------------------
2715 // Iframe dark mode
2716 //
2717 // Same-origin iframes are isolated documents: the parent's :root CSS variables,
2718 // stylesheet and dark-mode class do not cascade into them. To keep an iframe in
2719 // sync with the parent theme we mirror three things into the iframe document:
2720 // 1. the darkify_dark_mode_enabled class on <html>
2721 // 2. the theme CSS variables (kept live so colour/palette changes propagate)
2722 // 3. the plugin stylesheet, plus a run of the element classifier so inner
2723 // content (cards, sections, etc.) is darkened, not just <body>/<a>/inputs
2724 // Colour changes only need the variables refreshed; the var-driven class rules
2725 // then re-theme everything instantly without re-walking the DOM.
2726 // ---------------------------------------------------------------------------
2727
2728 const DARKIFY_IFRAME_THEME_VARS = [
2729 "--darkify_dark_mode_bg", "--darkify_dark_mode_secondary_bg",
2730 "--darkify_dark_mode_text_color", "--darkify_dark_mode_link_color",
2731 "--darkify_dark_mode_link_hover_color", "--darkify_dark_mode_input_bg",
2732 "--darkify_dark_mode_input_text_color", "--darkify_dark_mode_input_placeholder_color",
2733 "--darkify_dark_mode_border_color", "--darkify_dark_mode_btn_bg",
2734 "--darkify_dark_mode_btn_text_color", "--darkify_dark_mode_btn_hover_bg",
2735 "--darkify_dark_mode_btn_text_hover_color",
2736 "--darkify_dark_mode_btn_border_color",
2737 "--darkify_dark_mode_btn_hover_border_color",
2738 ];
2739
2740 // Tracks per-iframe-document observers so we don't attach duplicates and can
2741 // react to dynamically injected content (e.g. React/SPA pages inside the frame).
2742 const darkify_iframe_doc_observers = new WeakMap();
2743
2744 // Tracks per-iframe-document "keep our style last" observers so we attach only
2745 // one per document. Keyed on iframeDoc.
2746 const darkify_editor_head_watchers = new WeakMap();
2747
2748 // Serialise the parent's current theme variables as a :root {} rule. Computed
2749 // style is used so the value reflects the active theme regardless of whether it
2750 // was set inline (block editor) or via an inline <style> block (frontend).
2751 function darkify_serialize_root_vars() {
2752 const inline = document.documentElement.style;
2753 const computed = getComputedStyle(document.documentElement);
2754 let rootVars = ":root {";
2755 DARKIFY_IFRAME_THEME_VARS.forEach(function (varName) {
2756 const val = (
2757 inline.getPropertyValue(varName) || computed.getPropertyValue(varName)
2758 ).trim();
2759 if (val) rootVars += varName + ": " + val + ";";
2760 });
2761 return rootVars + "}";
2762 }
2763
2764 // Whether dark mode may apply to iframe content. The "Frontend Iframe Dark Mode"
2765 // option only affects the frontend — the Gutenberg editor canvas is unaffected.
2766 // Treated as enabled when the flag is absent (backward compatible: existing users
2767 // who haven't re-saved settings keep the previous default-on behaviour).
2768 function darkify_iframe_dark_enabled() {
2769 if (darkify_is_this_admin_panel === "1") return true;
2770 return (
2771 typeof darkify_enable_frontend_iframe_dark_mode === "undefined" ||
2772 darkify_enable_frontend_iframe_dark_mode === "1"
2773 );
2774 }
2775
2776 // Build the theme payload broadcast to iframes via postMessage. This is the
2777 // only channel that works for CROSS-ORIGIN iframes (e.g. an app served from a
2778 // different host/port), where the browser forbids touching contentDocument.
2779 // The receiving page applies these variables to its own theme.
2780 function darkify_build_theme_payload() {
2781 const inline = document.documentElement.style;
2782 const computed = getComputedStyle(document.documentElement);
2783 const vars = {};
2784 DARKIFY_IFRAME_THEME_VARS.forEach(function (varName) {
2785 const val = (
2786 inline.getPropertyValue(varName) || computed.getPropertyValue(varName)
2787 ).trim();
2788 if (val) vars[varName] = val;
2789 });
2790
2791 return {
2792 source: "darkify",
2793 type: "darkify-theme",
2794 // Honour the Frontend Iframe Dark Mode option here too, so the handshake
2795 // reply can't push dark mode into a cross-origin iframe when it is disabled.
2796 enabled:
2797 darkify_iframe_dark_enabled() &&
2798 document.documentElement.classList.contains("darkify_dark_mode_enabled"),
2799 vars: vars,
2800 };
2801 }
2802
2803 // Post the current theme to an iframe window. Works regardless of origin and is
2804 // silently ignored by frames that don't run the darkify receiver snippet.
2805 function darkify_post_theme_to_iframe(iframe, payload) {
2806 try {
2807 const win = iframe.contentWindow;
2808 if (win) win.postMessage(payload || darkify_build_theme_payload(), "*");
2809 } catch (e) {
2810 // ignore — frame not ready / inaccessible window reference
2811 }
2812 }
2813
2814 // Copy / refresh the theme variables inside an iframe document. Cheap and
2815 // idempotent — called on every theme change so colours stay in sync.
2816 function darkify_sync_iframe_vars(iframeDoc) {
2817 const head = iframeDoc.head || iframeDoc.documentElement;
2818 let style = iframeDoc.getElementById("darkify-iframe-vars");
2819 if (!style) {
2820 style = iframeDoc.createElement("style");
2821 style.id = "darkify-iframe-vars";
2822 head.appendChild(style);
2823 }
2824 style.textContent = darkify_serialize_root_vars();
2825 }
2826
2827 // Resolve the URL of the plugin's main stylesheet as loaded in the parent, so
2828 // the same var-driven .darkify_* rules can be injected into the iframe.
2829 function darkify_get_main_css_href() {
2830 const link = document.querySelector('link[href*="client_main"]');
2831 return link ? link.href : null;
2832 }
2833
2834 // Inject the plugin stylesheet (full class-based engine rules) plus a small
2835 // baseline so the frame is themed immediately, before/independent of the
2836 // element classifier pass.
2837 function darkify_inject_css_into_iframe(iframeDoc) {
2838 const head = iframeDoc.head || iframeDoc.documentElement;
2839
2840 if (!iframeDoc.getElementById("darkify-iframe-main-css")) {
2841 const href = darkify_get_main_css_href();
2842 if (href) {
2843 const link = iframeDoc.createElement("link");
2844 link.id = "darkify-iframe-main-css";
2845 link.rel = "stylesheet";
2846 link.href = href;
2847 head.appendChild(link);
2848 }
2849 }
2850
2851 if (iframeDoc.getElementById("darkify-iframe-css")) return;
2852
2853 const style = iframeDoc.createElement("style");
2854 style.id = "darkify-iframe-css";
2855 style.textContent = `
2856 html.darkify_dark_mode_enabled,
2857 html.darkify_dark_mode_enabled body {
2858 background: var(--darkify_dark_mode_secondary_bg) !important;
2859 color: var(--darkify_dark_mode_text_color) !important;
2860 }
2861
2862 html.darkify_dark_mode_enabled a {
2863 color: var(--darkify_dark_mode_link_color) !important;
2864 }
2865 html.darkify_dark_mode_enabled a:hover {
2866 color: var(--darkify_dark_mode_link_hover_color) !important;
2867 }
2868
2869 html.darkify_dark_mode_enabled input,
2870 html.darkify_dark_mode_enabled select,
2871 html.darkify_dark_mode_enabled textarea {
2872 background: var(--darkify_dark_mode_input_bg) !important;
2873 color: var(--darkify_dark_mode_input_text_color) !important;
2874 border-color: var(--darkify_dark_mode_border_color) !important;
2875 }
2876
2877 html.darkify_dark_mode_enabled input::placeholder,
2878 html.darkify_dark_mode_enabled textarea::placeholder {
2879 color: var(--darkify_dark_mode_input_placeholder_color) !important;
2880 }
2881
2882 /* TinyMCE editor body */
2883 html.darkify_dark_mode_enabled body#tinymce,
2884 html.darkify_dark_mode_enabled .mce-content-body {
2885 background: var(--darkify_dark_mode_secondary_bg) !important;
2886 color: var(--darkify_dark_mode_text_color) !important;
2887 }
2888 `;
2889 head.appendChild(style);
2890 }
2891 function darkify_inject_block_editor_css_into_iframe(iframeDoc) {
2892 const head = iframeDoc.head || iframeDoc.documentElement;
2893 let style = iframeDoc.getElementById("darkify-block-editor-css");
2894 if (!style) {
2895 style = iframeDoc.createElement("style");
2896 style.id = "darkify-block-editor-css";
2897 }
2898
2899 style.textContent = `
2900 /* ── Primary containers (covers all themes) ─────────────────── */
2901 html.darkify_dark_mode_enabled,
2902 html.darkify_dark_mode_enabled body,
2903 html.darkify_dark_mode_enabled body.editor-styles-wrapper,
2904 html.darkify_dark_mode_enabled body.block-editor-iframe__body,
2905 html.darkify_dark_mode_enabled .editor-styles-wrapper,
2906 html.darkify_dark_mode_enabled .is-root-container,
2907 html.darkify_dark_mode_enabled .wp-block-post-content,
2908 html.darkify_dark_mode_enabled .block-editor-block-list__layout,
2909 html.darkify_dark_mode_enabled .block-editor-iframe__body,
2910 html.darkify_dark_mode_enabled .wp-site-blocks,
2911 html.darkify_dark_mode_enabled .entry-content,
2912 html.darkify_dark_mode_enabled .site-content {
2913 background: var(--darkify_dark_mode_bg) !important;
2914 background-color: var(--darkify_dark_mode_bg) !important;
2915 color: var(--darkify_dark_mode_text_color) !important;
2916 }
2917
2918 /* ── Override CSS variables used by themes to drive backgrounds ─
2919 Kadence: --global-palette9 (bg), --global-palette1 (text)
2920 WordPress Global Styles: --wp--style--color--background */
2921 html.darkify_dark_mode_enabled body {
2922 --wp--style--color--background: var(--darkify_dark_mode_bg);
2923 --wp--preset--color--background: var(--darkify_dark_mode_bg);
2924 --wp--preset--color--base: var(--darkify_dark_mode_bg);
2925 --wp--preset--color--contrast: var(--darkify_dark_mode_text_color);
2926 --global-palette9: var(--darkify_dark_mode_bg);
2927 --global-palette8: var(--darkify_dark_mode_secondary_bg);
2928 --global-palette7: var(--darkify_dark_mode_secondary_bg);
2929 --global-palette1: var(--darkify_dark_mode_text_color);
2930 --global-palette2: var(--darkify_dark_mode_text_color);
2931 --global-palette3: var(--darkify_dark_mode_link_color);
2932 --global-palette6: var(--darkify_dark_mode_border_color);
2933 }
2934
2935 /* ── Text elements ──────────────────────────────────────────── */
2936 html.darkify_dark_mode_enabled p,
2937 html.darkify_dark_mode_enabled h1,
2938 html.darkify_dark_mode_enabled h2,
2939 html.darkify_dark_mode_enabled h3,
2940 html.darkify_dark_mode_enabled h4,
2941 html.darkify_dark_mode_enabled h5,
2942 html.darkify_dark_mode_enabled h6,
2943 html.darkify_dark_mode_enabled li,
2944 html.darkify_dark_mode_enabled td,
2945 html.darkify_dark_mode_enabled th,
2946 html.darkify_dark_mode_enabled blockquote,
2947 html.darkify_dark_mode_enabled pre,
2948 html.darkify_dark_mode_enabled span {
2949 color: var(--darkify_dark_mode_text_color) !important;
2950 }
2951
2952 /* ── Links ──────────────────────────────────────────────────── */
2953 html.darkify_dark_mode_enabled a {
2954 color: var(--darkify_dark_mode_link_color) !important;
2955 }
2956 html.darkify_dark_mode_enabled a:hover {
2957 color: var(--darkify_dark_mode_link_hover_color) !important;
2958 }
2959
2960 /* ── Form elements ──────────────────────────────────────────── */
2961 html.darkify_dark_mode_enabled input,
2962 html.darkify_dark_mode_enabled select,
2963 html.darkify_dark_mode_enabled textarea {
2964 background: var(--darkify_dark_mode_input_bg) !important;
2965 color: var(--darkify_dark_mode_input_text_color) !important;
2966 border-color: var(--darkify_dark_mode_border_color) !important;
2967 }
2968 html.darkify_dark_mode_enabled input::placeholder,
2969 html.darkify_dark_mode_enabled textarea::placeholder {
2970 color: var(--darkify_dark_mode_input_placeholder_color) !important;
2971 }
2972
2973 /* ── Blocks ─────────────────────────────────────────────────── */
2974 html.darkify_dark_mode_enabled .wp-block {
2975 color: var(--darkify_dark_mode_text_color) !important;
2976 }
2977 html.darkify_dark_mode_enabled img {
2978 filter: brightness(80%);
2979 }
2980 `;
2981
2982 // Always move to end of <head> so our rules load after any theme stylesheet.
2983 // head.appendChild is a no-op-safe move: if the element is already in the
2984 // tree it is first removed then re-inserted at the end.
2985 head.appendChild(style);
2986
2987 // Keep it last: observe for new <link>/<style> tags Kadence (or any theme)
2988 // injects after us and immediately re-append our style to the end.
2989 darkify_keep_editor_style_last(iframeDoc, style);
2990 }
2991
2992 /**
2993 * Run `fn` on the next frame, falling back to a task when the document has no
2994 * animation frames to give (a detached or hidden realm). The parent window's
2995 * clock is used deliberately: an iframe that is display:none never services its
2996 * own rAF, and the editor canvas is hidden for a beat during some transitions.
2997 */
2998 function darkify_next_frame(fn) {
2999 if (typeof requestAnimationFrame === "function") {
3000 requestAnimationFrame(fn);
3001 } else {
3002 setTimeout(fn, 0);
3003 }
3004 }
3005
3006 /**
3007 * Ceiling on how many times we will re-append our editor <style> to the end of
3008 * <head>. A normal editor boot inserts a few dozen stylesheets; anything past
3009 * this is two parties both insisting on being last, and the only way out is to
3010 * stop playing.
3011 */
3012 const DARKIFY_EDITOR_STYLE_MAX_MOVES = 100;
3013
3014 // MutationObserver that keeps darkify-block-editor-css as the last stylesheet
3015 // in the editor iframe <head>. Called once per iframeDoc (guarded by WeakMap).
3016 //
3017 // Re-appending our own <style> is itself a childList mutation on <head>, so this
3018 // observer always sees its own move. Alone that terminates — the
3019 // `lastElementChild` check exits on the second pass. It stops terminating when
3020 // something else also wants to be last: Gutenberg re-inserts block styles as
3021 // blocks register, and each of our moves provokes another of theirs. Coalescing
3022 // to one move per frame keeps that from running synchronously inside the
3023 // observer callback, and the move cap bounds it outright.
3024 function darkify_keep_editor_style_last(iframeDoc, ourStyle) {
3025 if (darkify_editor_head_watchers.has(iframeDoc)) return;
3026 const head = iframeDoc.head;
3027 if (!head) return;
3028
3029 let scheduled = false;
3030 let moves = 0;
3031
3032 const obs = new MutationObserver(function () {
3033 if (scheduled) return;
3034 // If our style is already last, nothing to do.
3035 if (head.lastElementChild === ourStyle) return;
3036
3037 scheduled = true;
3038 darkify_next_frame(function () {
3039 scheduled = false;
3040 // The head may have settled on its own while we waited for the frame.
3041 if (head.lastElementChild === ourStyle) return;
3042
3043 moves++;
3044 // A new sheet was added after ours — move us to the end.
3045 head.appendChild(ourStyle);
3046
3047 if (moves >= DARKIFY_EDITOR_STYLE_MAX_MOVES) {
3048 // Give up rather than keep trading appends forever. Whatever insists on
3049 // outranking us wins the cascade; a theme-coloured editor beats a frozen
3050 // one.
3051 obs.disconnect();
3052 darkify_editor_head_watchers.delete(iframeDoc);
3053 }
3054 });
3055 });
3056
3057 obs.observe(head, { childList: true });
3058 darkify_editor_head_watchers.set(iframeDoc, obs);
3059 }
3060
3061 /**
3062 * Per-iframe incremental walk state: the subtree roots added since the last
3063 * drain, and whether a drain is already booked for the next frame.
3064 */
3065 const darkify_iframe_walk_state = new WeakMap();
3066
3067 /**
3068 * Past this many queued roots, walking each one costs more than one flat pass
3069 * over the document — the `.darkify_processed` exclusion in the selector means
3070 * that pass only pays for nodes it has not already styled.
3071 */
3072 const DARKIFY_IFRAME_FULL_PASS_THRESHOLD = 200;
3073
3074 function darkify_process_iframe_element(element) {
3075 try {
3076 darkify_process_element(element);
3077 } catch (e) {
3078 // skip elements that can't be processed
3079 }
3080 }
3081
3082 /** Style one newly-added subtree: the root itself, then its descendants. */
3083 function darkify_walk_iframe_root(root) {
3084 if (!root.isConnected) return;
3085
3086 if (root.nodeType === 1 && !root.classList.contains("darkify_processed")) {
3087 let walkable = false;
3088 try {
3089 walkable = root.matches(DARKIFY_WALK_SELECTOR);
3090 } catch (e) {
3091 walkable = false;
3092 }
3093 if (walkable) {
3094 darkify_process_iframe_element(root);
3095 }
3096 }
3097
3098 if (root.querySelectorAll) {
3099 root
3100 .querySelectorAll(DARKIFY_WALK_SELECTOR)
3101 .forEach(darkify_process_iframe_element);
3102 }
3103 }
3104
3105 /** Flat pass over every not-yet-styled element in the iframe document. */
3106 function darkify_walk_iframe_all(iframeDoc) {
3107 if (!iframeDoc.documentElement) return;
3108 iframeDoc
3109 .querySelectorAll(DARKIFY_WALK_SELECTOR)
3110 .forEach(darkify_process_iframe_element);
3111 }
3112
3113 /** Drain one iframe's queued roots on the next frame. Idempotent per frame. */
3114 function darkify_schedule_iframe_walk(iframeDoc) {
3115 const state = darkify_iframe_walk_state.get(iframeDoc);
3116 if (!state || state.scheduled) return;
3117 state.scheduled = true;
3118
3119 darkify_next_frame(function () {
3120 state.scheduled = false;
3121 if (!iframeDoc.documentElement) return;
3122
3123 const roots = Array.from(state.roots);
3124 state.roots.clear();
3125 if (roots.length === 0) return;
3126
3127 if (roots.length >= DARKIFY_IFRAME_FULL_PASS_THRESHOLD) {
3128 darkify_walk_iframe_all(iframeDoc);
3129 return;
3130 }
3131
3132 roots.forEach(darkify_walk_iframe_root);
3133 });
3134 }
3135
3136 // Run the element classifier across an iframe document and keep watching it for
3137 // dynamically added nodes. window.getComputedStyle resolves styles of
3138 // same-origin iframe elements, so the existing engine works unchanged.
3139 //
3140 // The watch is incremental and frame-batched, mirroring the main document's
3141 // scheduler (see darkify_schedule_walk). It used to re-query and re-walk the
3142 // whole document on every mutation batch, with each visited element costing a
3143 // getComputedStyle — a forced style recalc. That is survivable on a page that
3144 // mutates occasionally and fatal in the block editor, where every keystroke is a
3145 // childList mutation: the editor stopped responding on any post long enough to
3146 // make the walk expensive.
3147 function darkify_run_engine_on_iframe(iframeDoc) {
3148 darkify_walk_iframe_all(iframeDoc);
3149
3150 if (darkify_iframe_doc_observers.has(iframeDoc)) return;
3151
3152 darkify_iframe_walk_state.set(iframeDoc, {
3153 roots: new Set(),
3154 scheduled: false,
3155 });
3156
3157 const observer = new MutationObserver(function (mutationsList) {
3158 const state = darkify_iframe_walk_state.get(iframeDoc);
3159 if (!state) return;
3160
3161 for (let i = 0; i < mutationsList.length; i++) {
3162 const added = mutationsList[i].addedNodes;
3163 for (let j = 0; j < added.length; j++) {
3164 const node = added[j];
3165 if (node.nodeType !== 1) continue;
3166 // Engine-generated nodes carry no design of their own to read, and
3167 // queueing them here is how an observer ends up feeding itself.
3168 if (
3169 node.classList &&
3170 (node.classList.contains("darkify_switch") ||
3171 node.classList.contains("darkify_ignore"))
3172 ) {
3173 continue;
3174 }
3175 state.roots.add(node);
3176 }
3177 }
3178
3179 if (state.roots.size > 0) {
3180 darkify_schedule_iframe_walk(iframeDoc);
3181 }
3182 });
3183
3184 observer.observe(iframeDoc.documentElement, {
3185 childList: true,
3186 subtree: true,
3187 });
3188 darkify_iframe_doc_observers.set(iframeDoc, observer);
3189 }
3190
3191 // Apply CSS invert filter to a cross-origin iframe element as a fallback dark
3192 // mode technique — the only browser-allowed approach when the embedded site
3193 // does not run the darkify receiver script.
3194 function darkify_apply_filter_to_iframe(iframe, enabled) {
3195 if (enabled) {
3196 iframe.style.filter = "brightness(0.6)";
3197 } else {
3198 iframe.style.filter = "";
3199 }
3200 }
3201
3202 // Detect whether an iframe is cross-origin by attempting to access its document.
3203 function darkify_is_cross_origin_iframe(iframe) {
3204 try {
3205 // Accessing contentDocument throws SecurityError for cross-origin frames.
3206 void (iframe.contentDocument || iframe.contentWindow?.document);
3207 return false;
3208 } catch (e) {
3209 return true;
3210 }
3211 }
3212 function darkify_apply_dark_to_iframe(iframe) {
3213 const isEditorCanvas = iframe.name === "editor-canvas";
3214 if (darkify_is_this_admin_panel === "1" && !isEditorCanvas) return;
3215
3216 const enabled = document.documentElement.classList.contains(
3217 "darkify_dark_mode_enabled",
3218 );
3219
3220 // Same-origin path: directly inject styles + run the engine. Throws a
3221 // SecurityError for cross-origin frames, which we swallow — those are handled
3222 // via postMessage + CSS filter fallback below.
3223 const applyDirect = function () {
3224 let iframeDoc;
3225 try {
3226 iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;
3227 } catch (e) {
3228 // Cross-origin: apply CSS filter to the iframe element as fallback.
3229 if (!isEditorCanvas && darkify_is_this_admin_panel !== "1") {
3230 darkify_apply_filter_to_iframe(
3231 iframe,
3232 document.documentElement.classList.contains(
3233 "darkify_dark_mode_enabled",
3234 ),
3235 );
3236 }
3237 return;
3238 }
3239 if (!iframeDoc || !iframeDoc.documentElement) return;
3240
3241 // Same-origin: clear any filter that was applied before we could access the doc.
3242 iframe.style.filter = "";
3243
3244 if (enabled) {
3245 iframeDoc.documentElement.classList.add("darkify_dark_mode_enabled");
3246 darkify_inject_css_into_iframe(iframeDoc);
3247 darkify_sync_iframe_vars(iframeDoc);
3248 if (isEditorCanvas) {
3249 darkify_inject_block_editor_css_into_iframe(iframeDoc);
3250 } else if (darkify_is_this_admin_panel !== "1") {
3251 darkify_run_engine_on_iframe(iframeDoc);
3252 }
3253 } else {
3254 iframeDoc.documentElement.classList.remove("darkify_dark_mode_enabled");
3255 }
3256 };
3257
3258 // Always broadcast the current theme first. This MUST run independently of the
3259 // (throwing) contentDocument access below, otherwise live toggles/palette
3260 // changes never reach a cross-origin iframe — they'd only sync on reload.
3261 darkify_post_theme_to_iframe(iframe);
3262
3263 // Re-broadcast + re-apply on every (re)load/navigation so content swapped
3264 // inside the frame is re-themed. Bind once per iframe to avoid stacking.
3265 if (!iframe.dataset.darkifyIframeBound) {
3266 iframe.dataset.darkifyIframeBound = "1";
3267 iframe.addEventListener("load", function () {
3268 darkify_post_theme_to_iframe(iframe);
3269 applyDirect();
3270 });
3271 }
3272
3273 applyDirect();
3274 }
3275 function darkify_process_iframes() {
3276 if (darkify_is_this_admin_panel === "1") {
3277 // Admin panel: only target the Gutenberg editor-canvas iframe
3278 const editorCanvas = document.querySelector('iframe[name="editor-canvas"]');
3279 if (editorCanvas) darkify_apply_dark_to_iframe(editorCanvas);
3280 return;
3281 }
3282
3283 // Respect the "Frontend Iframe Dark Mode" setting.
3284 // Existing users without this option saved get the default-on behaviour.
3285 if (!darkify_iframe_dark_enabled()) return;
3286
3287 document.querySelectorAll("iframe").forEach(darkify_apply_dark_to_iframe);
3288 }
3289
3290 // Watch the parent for theme changes (dark toggle, palette switch, customizer
3291 // live edits) and re-sync every iframe. darkify_process_iframes re-runs the
3292 // var sync, which is what propagates new colours into the frames.
3293 let darkify_parent_theme_observer = null;
3294 let darkify_iframe_sync_scheduled = false;
3295 function darkify_schedule_iframe_sync() {
3296 if (darkify_iframe_sync_scheduled) return;
3297 darkify_iframe_sync_scheduled = true;
3298 requestAnimationFrame(function () {
3299 darkify_iframe_sync_scheduled = false;
3300 darkify_process_iframes();
3301 });
3302 }
3303 function darkify_watch_parent_theme() {
3304 if (darkify_parent_theme_observer) return;
3305
3306 darkify_parent_theme_observer = new MutationObserver(darkify_schedule_iframe_sync);
3307
3308 // <html> class (dark on/off) and inline style (block-editor palette vars).
3309 darkify_parent_theme_observer.observe(document.documentElement, {
3310 attributes: true,
3311 attributeFilter: ["class", "style"],
3312 });
3313
3314 // Frontend palette variables live in an inline <style> block; watch its text
3315 // so customizer / dynamic edits to the :root variables are picked up too.
3316 const inlineCss = document.querySelector("style.darkify_inline_css");
3317 if (inlineCss) {
3318 darkify_parent_theme_observer.observe(inlineCss, {
3319 childList: true,
3320 characterData: true,
3321 subtree: true,
3322 });
3323 }
3324
3325 // In the admin panel, Gutenberg inserts the editor-canvas iframe into the DOM
3326 // asynchronously — after the initial darkify_process_iframes() call has
3327 // already run. Watch document.body so we re-sync the moment it appears.
3328 if (darkify_is_this_admin_panel === "1" && document.body) {
3329 var darkify_canvas_dom_observer = new MutationObserver(function () {
3330 if (document.querySelector('iframe[name="editor-canvas"]')) {
3331 darkify_schedule_iframe_sync();
3332 }
3333 });
3334 darkify_canvas_dom_observer.observe(document.body, {
3335 childList: true,
3336 subtree: true,
3337 });
3338 }
3339 }
3340
3341 if (!_dkf_iframe_disabled) {
3342 if (document.readyState !== "loading") {
3343 darkify_watch_parent_theme();
3344 } else {
3345 document.addEventListener("DOMContentLoaded", darkify_watch_parent_theme);
3346 }
3347 }
3348
3349 // Handshake: an iframe that loads after — or before — the parent is ready can
3350 // ask for the current theme, and we reply to that frame directly. This makes
3351 // initial sync reliable regardless of which side finishes loading first.
3352 window.addEventListener("message", function (event) {
3353 const data = event.data;
3354 if (!data || data.source !== "darkify" || data.type !== "darkify-request-theme")
3355 return;
3356 try {
3357 if (event.source) {
3358 event.source.postMessage(darkify_build_theme_payload(), "*");
3359 }
3360 } catch (e) {
3361 // ignore unreachable source window
3362 }
3363 });
3364
3365 function darkify_init_keyboard_shortcut_listener() {
3366 if (darkify_enable_keyboard_shortcut === "1") {
3367 // The combo is a normalized string set in the admin (e.g. "ctrl+alt+d"):
3368 // modifiers in any order plus one key. Match on the PHYSICAL key (event.code)
3369 // so macOS Option-diacritics don't break it.
3370 var combo =
3371 typeof darkify_keyboard_shortcut_keys === "string" &&
3372 darkify_keyboard_shortcut_keys
3373 ? darkify_keyboard_shortcut_keys.toLowerCase()
3374 : "ctrl+alt+d";
3375 var parts = combo.split("+");
3376 var need_ctrl = parts.indexOf("ctrl") !== -1;
3377 var need_alt = parts.indexOf("alt") !== -1;
3378 var need_shift = parts.indexOf("shift") !== -1;
3379 var need_meta = parts.indexOf("meta") !== -1;
3380 var need_key = parts[parts.length - 1];
3381 var expected_code = null;
3382 if (/^[a-z]$/.test(need_key)) {
3383 expected_code = "key" + need_key;
3384 } else if (/^[0-9]$/.test(need_key)) {
3385 expected_code = "digit" + need_key;
3386 }
3387 document.onkeydown = function (event) {
3388 var key_matches =
3389 (expected_code &&
3390 typeof event.code === "string" &&
3391 event.code.toLowerCase() === expected_code) ||
3392 (typeof event.key === "string" &&
3393 event.key.toLowerCase() === need_key);
3394 if (
3395 event.ctrlKey === need_ctrl &&
3396 event.altKey === need_alt &&
3397 event.shiftKey === need_shift &&
3398 event.metaKey === need_meta &&
3399 key_matches
3400 ) {
3401 event.preventDefault();
3402 darkify_switch_trigger();
3403 }
3404 };
3405 }
3406 }
3407
3408 function darkify_init_os_mode_change_listener() {
3409 if (darkify_is_this_admin_panel === "0" && darkify_enable_os_aware === "1") {
3410 window
3411 .matchMedia("(prefers-color-scheme: dark)")
3412 .addEventListener("change", (event) => {
3413 const mode = event.matches ? "dark" : "light";
3414 const htmlElement = document.getElementsByTagName("html")[0];
3415 if (mode === "dark") {
3416 htmlElement.classList.add("darkify_dark_mode_enabled");
3417 } else if (mode === "light") {
3418 htmlElement.classList.remove("darkify_dark_mode_enabled");
3419 }
3420
3421 darkify_change_state();
3422 });
3423 }
3424 }
3425
3426 function darkify_init_alternative_dark_mode_switch() {
3427 if (darkify_alternative_dark_mode_switch.length > 0) {
3428 const elements = document.querySelectorAll(
3429 darkify_alternative_dark_mode_switch,
3430 );
3431 for (let i = 0; i < elements.length; i++) {
3432 const element = elements[i];
3433 element.addEventListener("click", () => {
3434 darkify_switch_trigger();
3435 });
3436 }
3437 }
3438 }
3439
3440 function darkify_init_attention_effect() {
3441 if (darkify_enable_switch_attention !== "1") return;
3442 if (!darkify_switch_attention_effect || darkify_switch_attention_effect === "none") return;
3443 var switchEl = document.getElementById("darkify_switch_" + darkify_switch_unique_id);
3444 if (switchEl) {
3445 switchEl.classList.add("darkify_attention_" + darkify_switch_attention_effect);
3446 }
3447 }
3448
3449 function get_bg_color_to_preserve(element, fromDataset) {
3450 let color = window.getComputedStyle(element, null).backgroundColor;
3451 if (!fromDataset) {
3452 color = element.dataset.darkify_preserved_bg;
3453 }
3454 if (
3455 (color === "transparent" ||
3456 color === "rgba(0, 0, 0, 0)" ||
3457 color === "rgba(255,255,255,0)") &&
3458 element.parentNode.nodeType === 1
3459 ) {
3460 color = get_bg_color_to_preserve(element.parentNode, false);
3461 } else if (
3462 element.parentNode.nodeType === 1 &&
3463 element.parentNode.hasAttribute("data-darkify_preserved_bg") &&
3464 window.getComputedStyle(element.parentNode, null).backgroundColor === color
3465 ) {
3466 color = get_bg_color_to_preserve(element.parentNode, false);
3467 }
3468 return color;
3469 }
3470
3471 function get_txt_color_to_preserve(element, fromDataset) {
3472 let color = window.getComputedStyle(element, null).color;
3473 if (!fromDataset) {
3474 color = element.dataset.darkify_preserved_color;
3475 }
3476 if (
3477 (color === "transparent" ||
3478 color === "rgba(0, 0, 0, 0)" ||
3479 color === "rgba(255,255,255,0)") &&
3480 element.parentNode.nodeType === 1
3481 ) {
3482 color = get_txt_color_to_preserve(element.parentNode, false);
3483 } else if (
3484 element.parentNode.nodeType === 1 &&
3485 element.parentNode.hasAttribute("data-darkify_preserved_color") &&
3486 window.getComputedStyle(element.parentNode, null).color === color
3487 ) {
3488 color = get_txt_color_to_preserve(element.parentNode, false);
3489 }
3490 return color;
3491 }
3492
3493 /**
3494 * Whether a black wash can be folded into this layer's background image.
3495 *
3496 * The darkener works by prepending an opaque-black gradient to `background-image`,
3497 * which assumes the layer is composited normally — then the result really is
3498 * "the picture, dimmed".
3499 *
3500 * A layer with a blend mode breaks that assumption, and `multiply` breaks it
3501 * completely: multiply against black is black, whatever is underneath. A
3502 * builder's texture overlay is drawn exactly that way — a pale watermark
3503 * multiplied over the section at low opacity, which in light mode tints it
3504 * faintly. Fold a 60%-black wash into that and the overlay stops being a
3505 * watermark and becomes a dark rectangle stamped over the section, with a hard
3506 * seam along its edge. It reads as a patch of the page that failed to convert,
3507 * which is the opposite of what the darkener is for.
3508 *
3509 * Left alone, such a layer needs no help: it multiplies a pale image over an
3510 * already-dark surface and all but disappears, which is the right outcome.
3511 */
3512 /** Every background property that is a per-layer list, in the order it is written. */
3513 var DARKIFY_DARKEN_LAYER_PROPS = [
3514 "background-image",
3515 "background-size",
3516 "background-repeat",
3517 "background-position",
3518 "background-origin",
3519 "background-clip",
3520 "background-attachment",
3521 ];
3522
3523 /**
3524 * The per-layer geometry a prepended darkening layer needs to cover its box.
3525 *
3526 * `background-image` is a list — and so are `background-size`, `-repeat`,
3527 * `-position`, `-origin`, `-clip` and `-attachment`. Each layer takes the value
3528 * at its own index, and a list shorter than the image list is cycled. So
3529 * prepending a wash to `background-image` alone does not add a layer carrying
3530 * sane defaults: it shifts every other list by one, and the wash silently
3531 * inherits whatever geometry the design wrote for the picture.
3532 *
3533 * Where that geometry is `cover` / `repeat`, the wash happens to fill the box
3534 * and the result looks right, which is why this went unnoticed for so long.
3535 * Where it is anything else, the wash stops covering the element. A decorative
3536 * watermark drawn at `background-size: 37%` with `no-repeat` turns the wash into
3537 * a hard-edged black rectangle over 37% of the section — a dark panel with
3538 * visible corners, in the shape of nothing the design contains. `contain`,
3539 * pixel sizes, positioned icons and sprite sheets all fail the same way.
3540 *
3541 * Naming the wash's own geometry is what makes it a layer instead of a shift: it
3542 * fills the border box exactly, once, however the picture beneath it is sized,
3543 * tiled, positioned or clipped. Prepending (rather than replacing) leaves every
3544 * value the design wrote still attached to the layer it was written for —
3545 * including the last `background-clip`, which is the one that clips the
3546 * background colour.
3547 */
3548 function darkify_darken_layer_geometry(style) {
3549 return {
3550 "background-size": "100% 100%, " + style.backgroundSize,
3551 "background-repeat": "no-repeat, " + style.backgroundRepeat,
3552 "background-position": "0% 0%, " + style.backgroundPosition,
3553 "background-origin": "border-box, " + style.backgroundOrigin,
3554 "background-clip": "border-box, " + style.backgroundClip,
3555 "background-attachment": "scroll, " + style.backgroundAttachment,
3556 };
3557 }
3558
3559 /** Those declarations as CSS text, for the rules the pseudo paths write. */
3560 function darkify_darken_layer_css(style) {
3561 var geometry = darkify_darken_layer_geometry(style);
3562 var css = "";
3563 Object.keys(geometry).forEach(function (prop) {
3564 css += prop + ": " + geometry[prop] + " !important;";
3565 });
3566 return css;
3567 }
3568
3569 function darkify_can_darken_layer(style) {
3570 if (!style) {
3571 return false;
3572 }
3573
3574 var blend = style.mixBlendMode || style.getPropertyValue("mix-blend-mode");
3575 return !blend || blend === "normal";
3576 }
3577
3578 /**
3579 * Measured mean luminance per image URL, so one picture is sampled once for the
3580 * whole page. `null` means "sampled and unusable" — a cross-origin image whose
3581 * pixels a canvas may not read, or one that failed to load — and the wash falls
3582 * back to the configured level for those.
3583 */
3584 var DARKIFY_IMAGE_LUMA = {};
3585 var DARKIFY_IMAGE_LUMA_WAITING = {};
3586
3587 /** At or below this mean luminance a picture needs no wash at all. */
3588 var DARKIFY_IMAGE_DARK_FLOOR = 0.16;
3589 /** At or above it, the wash is applied at the level the user configured. */
3590 var DARKIFY_IMAGE_DARK_CEIL = 0.5;
3591
3592 /** The first `url(...)` in a `background-image` list, unquoted. */
3593 function darkify_first_image_url(value) {
3594 var match = value && value.match(/url\((['"]?)([^'")]+)\1\)/);
3595 return match ? match[2] : "";
3596 }
3597
3598 /**
3599 * Sample a picture's mean luminance, once, off a 16x16 canvas.
3600 *
3601 * The size is deliberate: the wash only needs to know "is this picture bright
3602 * or is it already dark", and drawing a 1850px hero down to 256 pixels answers
3603 * that for the cost of one small draw. Alpha is weighted in, so a mostly
3604 * transparent PNG doesn't read as black.
3605 *
3606 * A cross-origin image without CORS headers taints the canvas and `getImageData`
3607 * throws; that is caught and cached as `null` rather than retried, so a page
3608 * full of third-party images doesn't sample the same failure on every pass.
3609 */
3610 function darkify_measure_image_luma(url, done) {
3611 if (Object.prototype.hasOwnProperty.call(DARKIFY_IMAGE_LUMA, url)) {
3612 done(DARKIFY_IMAGE_LUMA[url]);
3613 return;
3614 }
3615 if (DARKIFY_IMAGE_LUMA_WAITING[url]) {
3616 DARKIFY_IMAGE_LUMA_WAITING[url].push(done);
3617 return;
3618 }
3619 DARKIFY_IMAGE_LUMA_WAITING[url] = [done];
3620
3621 var finish = function (value) {
3622 DARKIFY_IMAGE_LUMA[url] = value;
3623 var waiting = DARKIFY_IMAGE_LUMA_WAITING[url] || [];
3624 delete DARKIFY_IMAGE_LUMA_WAITING[url];
3625 waiting.forEach(function (callback) {
3626 callback(value);
3627 });
3628 };
3629
3630 var img = new Image();
3631 img.crossOrigin = "anonymous";
3632 img.onload = function () {
3633 try {
3634 var size = 16;
3635 var canvas = document.createElement("canvas");
3636 canvas.width = size;
3637 canvas.height = size;
3638 var ctx = canvas.getContext("2d", { willReadFrequently: true });
3639 ctx.drawImage(img, 0, 0, size, size);
3640 var data = ctx.getImageData(0, 0, size, size).data;
3641 var total = 0;
3642 var count = 0;
3643 for (var i = 0; i < data.length; i += 4) {
3644 var alpha = data[i + 3] / 255;
3645 if (alpha === 0) {
3646 continue;
3647 }
3648 total +=
3649 ((0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2]) /
3650 255) *
3651 alpha;
3652 count++;
3653 }
3654 finish(count ? total / count : null);
3655 } catch (e) {
3656 finish(null);
3657 }
3658 };
3659 img.onerror = function () {
3660 finish(null);
3661 };
3662 img.src = url;
3663 }
3664
3665 /**
3666 * Scale the configured wash by how bright the picture under it actually is.
3667 *
3668 * The wash exists to stop a bright light-mode photo glaring out of a dark page.
3669 * A picture that is already dark — a near-black hero, a deep-green pattern —
3670 * has nothing to dim, and stamping 60% black over it does not darken the design,
3671 * it erases it: the section becomes a flat black band and whatever shape or
3672 * texture the picture carried is gone. That is the same mistake the scrim rule
3673 * above fixes for gradients, one layer down.
3674 *
3675 * Below the floor the wash is dropped entirely, above the ceiling it is applied
3676 * in full, and in between it ramps, so there is no visible step between a
3677 * picture that just cleared the floor and one that just missed it.
3678 *
3679 * @return {string} A level string in the same one-decimal form the caller's
3680 * configured level uses, "0.0" meaning "no wash".
3681 */
3682 function darkify_darken_level_for_image(level, url) {
3683 var configured = parseFloat(level) || 0;
3684 if (!url || !Object.prototype.hasOwnProperty.call(DARKIFY_IMAGE_LUMA, url)) {
3685 return String(level);
3686 }
3687
3688 var luma = DARKIFY_IMAGE_LUMA[url];
3689 if (luma === null || luma === undefined) {
3690 return String(level);
3691 }
3692 if (luma <= DARKIFY_IMAGE_DARK_FLOOR) {
3693 return "0.0";
3694 }
3695
3696 var scale = Math.min(
3697 1,
3698 (luma - DARKIFY_IMAGE_DARK_FLOOR) /
3699 (DARKIFY_IMAGE_DARK_CEIL - DARKIFY_IMAGE_DARK_FLOOR),
3700 );
3701 return (configured * scale).toFixed(1);
3702 }
3703
3704 /**
3705 * The style element carrying one generated box's wash.
3706 *
3707 * The id is stored on the element and reused, rather than regenerated per call:
3708 * the wash is re-applied whenever a measurement comes back, and a fresh random
3709 * id each time would leave the previous `<style>` in the head still painting the
3710 * old, full-strength wash through a selector that still matches.
3711 */
3712 function darkify_darken_pseudo_style(element, pseudo) {
3713 var attribute = "data-darkify-" + pseudo + "-style-id";
3714 var styleId = element.getAttribute(attribute);
3715 if (!styleId) {
3716 styleId =
3717 "darkify-" + pseudo + "-" + Math.random().toString(36).substr(2, 9);
3718 element.setAttribute(attribute, styleId);
3719 }
3720
3721 var styleElement = document.getElementById(styleId);
3722 if (!styleElement) {
3723 styleElement = document.createElement("style");
3724 styleElement.id = styleId;
3725 document.head.appendChild(styleElement);
3726 }
3727
3728 return { id: styleId, node: styleElement };
3729 }
3730
3731 function darkify_darken_bg_image(element, level) {
3732 if (
3733 document
3734 .getElementsByTagName("html")[0]
3735 .classList.contains("darkify_dark_mode_enabled")
3736 ) {
3737 const mainStyle = window.getComputedStyle(element, null);
3738 const beforeStyle = window.getComputedStyle(element, ":before");
3739 const afterStyle = window.getComputedStyle(element, ":after");
3740
3741 // Any picture on this element that hasn't been sampled yet is sampled now,
3742 // and the wash is re-applied once the measurement lands. Until then the
3743 // configured level stands: over-darkening for a frame is recoverable, while
3744 // showing a bright light-mode photo on a dark page for a frame is the flash
3745 // this engine exists to avoid.
3746 var pending = [];
3747 [mainStyle, beforeStyle, afterStyle].forEach(function (style) {
3748 if (!style || !style.backgroundImage) {
3749 return;
3750 }
3751 var url = darkify_first_image_url(style.backgroundImage);
3752 if (
3753 url &&
3754 !Object.prototype.hasOwnProperty.call(DARKIFY_IMAGE_LUMA, url) &&
3755 pending.indexOf(url) === -1
3756 ) {
3757 pending.push(url);
3758 }
3759 });
3760
3761 if (pending.length) {
3762 var remaining = pending.length;
3763 pending.forEach(function (url) {
3764 darkify_measure_image_luma(url, function () {
3765 remaining--;
3766 if (remaining > 0 || !element.isConnected) {
3767 return;
3768 }
3769 // Re-apply from the design's own values: the inline wash written
3770 // below is restored first so this pass measures the picture, not the
3771 // last pass's output.
3772 if (element.dataset && element.dataset.darkifyDarkenPrev) {
3773 darkify_restore_inline(
3774 element,
3775 "darkifyDarkenPrev",
3776 DARKIFY_DARKEN_LAYER_PROPS,
3777 );
3778 }
3779 darkify_darken_bg_image(element, level);
3780 });
3781 });
3782 }
3783
3784 var mainLevel = darkify_darken_level_for_image(
3785 level,
3786 darkify_first_image_url(mainStyle.backgroundImage),
3787 );
3788
3789 if (
3790 darkify_can_darken_layer(mainStyle) &&
3791 mainStyle.backgroundImage !== "none" &&
3792 mainStyle.backgroundImage.includes("url") &&
3793 mainLevel !== "0.0" &&
3794 !mainStyle.backgroundImage.includes("rgba(0, 0, 0, " + mainLevel + ")")
3795 ) {
3796 darkify_store_inline(
3797 element,
3798 "darkifyDarkenPrev",
3799 DARKIFY_DARKEN_LAYER_PROPS,
3800 );
3801
3802 element.style.setProperty(
3803 "background-image",
3804 "linear-gradient(rgba(0, 0, 0, " +
3805 mainLevel +
3806 "), rgba(0, 0, 0, " +
3807 mainLevel +
3808 ")), " +
3809 mainStyle.backgroundImage,
3810 );
3811
3812 var main_geometry = darkify_darken_layer_geometry(mainStyle);
3813 Object.keys(main_geometry).forEach(function (prop) {
3814 element.style.setProperty(prop, main_geometry[prop]);
3815 });
3816 }
3817
3818 // Process :before pseudo-element
3819 var beforeLevel = darkify_darken_level_for_image(
3820 level,
3821 darkify_first_image_url(beforeStyle.backgroundImage),
3822 );
3823
3824 if (
3825 darkify_can_darken_layer(beforeStyle) &&
3826 beforeStyle.backgroundImage !== "none" &&
3827 beforeStyle.backgroundImage.includes("url") &&
3828 !beforeStyle.backgroundImage.includes("rgba(0, 0, 0, " + beforeLevel + ")")
3829 ) {
3830 const beforeSheet = darkify_darken_pseudo_style(element, "before");
3831
3832 // Store original background for reset
3833 element.dataset.darkifyOriginalBeforeBg = beforeStyle.backgroundImage;
3834
3835 // A picture dark enough to need no wash still gets its rule cleared —
3836 // an earlier pass may have written a full-strength one before the
3837 // measurement came back.
3838 if (beforeLevel === "0.0") {
3839 beforeSheet.node.textContent = "";
3840 } else {
3841 beforeSheet.node.textContent = `
3842 .darkify_dark_mode_enabled [data-darkify-before-style-id="${beforeSheet.id}"]::before {
3843 background-image: linear-gradient(rgba(0, 0, 0, ${beforeLevel}), rgba(0, 0, 0, ${beforeLevel})), ${beforeStyle.backgroundImage} !important;
3844 ${darkify_darken_layer_css(beforeStyle)}
3845 }
3846 `;
3847
3848 // Ensure position relative on parent
3849 if (window.getComputedStyle(element).position === "static") {
3850 element.style.position = "relative";
3851 }
3852 }
3853 }
3854
3855 // Process :after pseudo-element
3856 var afterLevel = darkify_darken_level_for_image(
3857 level,
3858 darkify_first_image_url(afterStyle.backgroundImage),
3859 );
3860
3861 if (
3862 darkify_can_darken_layer(afterStyle) &&
3863 afterStyle.backgroundImage !== "none" &&
3864 afterStyle.backgroundImage.includes("url") &&
3865 !afterStyle.backgroundImage.includes("rgba(0, 0, 0, " + afterLevel + ")")
3866 ) {
3867 const afterSheet = darkify_darken_pseudo_style(element, "after");
3868
3869 // Store original background for reset
3870 element.dataset.darkifyOriginalAfterBg = afterStyle.backgroundImage;
3871
3872 if (afterLevel === "0.0") {
3873 afterSheet.node.textContent = "";
3874 } else {
3875 afterSheet.node.textContent = `
3876 .darkify_dark_mode_enabled [data-darkify-after-style-id="${afterSheet.id}"]::after {
3877 background-image: linear-gradient(rgba(0, 0, 0, ${afterLevel}), rgba(0, 0, 0, ${afterLevel})), ${afterStyle.backgroundImage} !important;
3878 ${darkify_darken_layer_css(afterStyle)}
3879 }
3880 `;
3881
3882 // Ensure position relative on parent
3883 if (window.getComputedStyle(element).position === "static") {
3884 element.style.position = "relative";
3885 }
3886 }
3887 }
3888 } else if (element.dataset && element.dataset.darkifyDarkenPrev) {
3889 // Restore from the saved inline declarations rather than by editing the
3890 // computed value back: the wash now writes six geometry properties beside
3891 // the image, and unpicking a string only ever put the image back — the
3892 // element kept the wash's `background-size`/`-repeat` in light mode, which
3893 // re-sized the design's own picture.
3894 darkify_restore_inline(
3895 element,
3896 "darkifyDarkenPrev",
3897 DARKIFY_DARKEN_LAYER_PROPS,
3898 );
3899 }
3900 }
3901
3902 function darkify_img_brightness_and_grayscale(element) {
3903 if (
3904 document
3905 .getElementsByTagName("html")[0]
3906 .classList.contains("darkify_dark_mode_enabled")
3907 ) {
3908 if (
3909 !element.classList.contains("darkify_changed_brightness_and_grayscale")
3910 ) {
3911 element.dataset.darkify_preserved_filter = element.style.filter;
3912 element.classList.add("darkify_changed_brightness_and_grayscale");
3913
3914 if (
3915 darkify_enable_low_image_brightness === "1" &&
3916 darkify_enable_image_grayscale === "1"
3917 ) {
3918 element.style.filter =
3919 "brightness(" +
3920 darkify_image_brightness_to +
3921 "%)" +
3922 " " +
3923 "grayscale(" +
3924 darkify_image_grayscale_to +
3925 "%)";
3926 } else {
3927 if (darkify_enable_low_image_brightness === "1") {
3928 element.style.filter =
3929 "brightness(" + darkify_image_brightness_to + "%)";
3930 } else if (darkify_enable_image_grayscale === "1") {
3931 element.style.filter =
3932 "grayscale(" + darkify_image_grayscale_to + "%)";
3933 }
3934 }
3935 }
3936 } else if (
3937 element.classList.contains("darkify_changed_brightness_and_grayscale")
3938 ) {
3939 element.style.filter = element.dataset.darkify_preserved_filter;
3940 element.classList.remove("darkify_changed_brightness_and_grayscale");
3941 delete element.dataset.darkify_preserved_filter;
3942 }
3943 }
3944
3945 function darkify_invert_inline_svg(element) {
3946 if (document.body.classList.contains("block-editor-page")) return;
3947 if (
3948 document
3949 .getElementsByTagName("html")[0]
3950 .classList.contains("darkify_dark_mode_enabled")
3951 ) {
3952 element.style.filter = "invert(1)";
3953 element.classList.add("darkify_inverted_inline_svg");
3954 } else if (element.classList.contains("darkify_inverted_inline_svg")) {
3955 element.style.filter = element.style.filter.replace("invert(1)", "");
3956 element.classList.remove("darkify_inverted_inline_svg");
3957 }
3958 }
3959
3960 function darkify_video_brightness_and_grayscale(element) {
3961 if (
3962 document
3963 .getElementsByTagName("html")[0]
3964 .classList.contains("darkify_dark_mode_enabled")
3965 ) {
3966 if (
3967 !element.classList.contains(
3968 "darkify_changed_video_brightness_and_grayscale",
3969 )
3970 ) {
3971 element.dataset.darkify_preserved_filter = element.style.filter;
3972 element.classList.add("darkify_changed_video_brightness_and_grayscale");
3973 if (
3974 darkify_enable_low_video_brightness === "1" &&
3975 darkify_enable_video_grayscale === "1"
3976 ) {
3977 element.style.filter =
3978 "brightness(" +
3979 darkify_video_brightness_to +
3980 "%)" +
3981 " " +
3982 "grayscale(" +
3983 darkify_video_grayscale_to +
3984 "%)";
3985 } else {
3986 if (darkify_enable_low_video_brightness === "1") {
3987 element.style.filter =
3988 "brightness(" + darkify_video_brightness_to + "%)";
3989 } else if (darkify_enable_video_grayscale === "1") {
3990 element.style.filter =
3991 "grayscale(" + darkify_video_grayscale_to + "%)";
3992 }
3993 }
3994 }
3995 } else if (
3996 element.classList.contains("darkify_changed_video_brightness_and_grayscale")
3997 ) {
3998 element.style.filter = element.dataset.darkify_preserved_filter;
3999 element.classList.remove("darkify_changed_video_brightness_and_grayscale");
4000 delete element.dataset.darkify_preserved_filter;
4001 }
4002 }
4003
4004 function darkify_replace_video(videoElement, videos) {
4005 if (
4006 document
4007 .getElementsByTagName("html")[0]
4008 .classList.contains("darkify_dark_mode_enabled")
4009 ) {
4010 for (let i = 0; i < videos.length; i++) {
4011 const normalVideo = videos[i].normal_video;
4012 const normalVideoPath = new URL(normalVideo).pathname;
4013 const darkVideo = videos[i].dark_video;
4014 const darkVideoPath = new URL(darkVideo).pathname;
4015
4016 if (
4017 videoElement.getAttribute("src") != null &&
4018 videoElement.getAttribute("src").includes(normalVideoPath)
4019 ) {
4020 videoElement.src = darkVideo;
4021 videoElement.classList.add("darkify_replaced_video");
4022 }
4023
4024 if (videoElement.querySelectorAll("source") != null) {
4025 let sources = videoElement.querySelectorAll("source");
4026 for (let j = 0; j < sources.length; j++) {
4027 if (
4028 sources[j].getAttribute("src") != null &&
4029 sources[j].getAttribute("src").includes(normalVideoPath)
4030 ) {
4031 sources[j].src = darkVideo + "?_=" + Date.now();
4032 videoElement.classList.add("darkify_replaced_video");
4033 videoElement.load();
4034 }
4035 }
4036 }
4037 }
4038 } else {
4039 if (videoElement.classList.contains("darkify_replaced_video")) {
4040 for (let i = 0; i < videos.length; i++) {
4041 const normalVideo = videos[i].normal_video;
4042 const normalVideoPath = new URL(normalVideo).pathname;
4043 const darkVideo = videos[i].dark_video;
4044 const darkVideoPath = new URL(darkVideo).pathname;
4045
4046 if (
4047 videoElement.getAttribute("src") != null &&
4048 videoElement.getAttribute("src").includes(darkVideoPath)
4049 ) {
4050 videoElement.src = normalVideo;
4051 videoElement.classList.remove("darkify_replaced_video");
4052 }
4053
4054 if (videoElement.querySelectorAll("source") != null) {
4055 let sources = videoElement.querySelectorAll("source");
4056 for (let j = 0; j < sources.length; j++) {
4057 if (
4058 sources[j].getAttribute("src") != null &&
4059 sources[j].getAttribute("src").includes(darkVideoPath)
4060 ) {
4061 sources[j].src = normalVideo + "?_=" + Date.now();
4062 videoElement.classList.remove("darkify_replaced_video");
4063 videoElement.load();
4064 }
4065 }
4066 }
4067 }
4068 }
4069 }
4070 }
4071
4072 /* ==========================================================================
4073 Generated-box surfaces — ::before / ::after
4074 --------------------------------------------------------------------------
4075 Every writer above reaches an element by putting something ON it: a
4076 `darkify_style_*` class, or an inline declaration. A generated box has no
4077 node to put anything on, so all of them are blind to it.
4078
4079 That blind spot is exactly where a modern page builder keeps its section
4080 backgrounds. Spectra, Elementor, Kadence, Divi and GenerateBlocks all paint a
4081 container's gradient, overlay or tint on `::before`/`::after` rather than on
4082 the container itself, because a generated box can be stacked, blended and
4083 faded independently of the content sitting above it. The container is left
4084 `background-color: transparent`.
4085
4086 To the classifier such a container looks like a bare text wrapper, so it is
4087 given `darkify_style_txt*`: its copy is repainted for a dark page while the
4088 surface behind that copy is still the light-mode gradient. That is the
4089 washed-out hero — pale grey type on a white section, unreadable — and it gets
4090 worse the more of a design lives in the builder rather than in the theme.
4091
4092 A stylesheet rule is the only thing that can reach a generated box, so this
4093 layer writes one. Each element that needs it gets a short id, and one rule per
4094 pseudo goes into a single shared stylesheet keyed on that id. Three things
4095 fall out of doing this in CSS rather than inline:
4096
4097 * the rules are prefixed with `.darkify_dark_mode_enabled`, so switching
4098 back to light unpaints them with no restore pass and nothing to remember;
4099 * the colours come from the same darkify_transform_color() the element path
4100 uses, so the palette presets, Brand Colors, the gradient setting and the
4101 manual colour overrides all apply to a generated box exactly as they do
4102 to a real one, with no second implementation to keep in step;
4103 * one stylesheet replaces the one-<style>-node-per-element the previous
4104 pseudo handler created, which on a builder page meant hundreds of nodes
4105 in <head> and a style recalculation for each of them.
4106 ========================================================================== */
4107
4108 var DARKIFY_PSEUDO_NAMES = ["before", "after"];
4109
4110 /**
4111 * Elements that never render generated content.
4112 *
4113 * Listed to be skipped rather than handled: `::before` on an `<img>` or an
4114 * `<input>` produces no box at all, so probing one costs two style resolutions
4115 * and can only ever return nothing. On an image-heavy page that is most of the
4116 * document.
4117 */
4118 var DARKIFY_NO_GENERATED_BOX = {
4119 img: 1,
4120 br: 1,
4121 hr: 1,
4122 input: 1,
4123 select: 1,
4124 textarea: 1,
4125 option: 1,
4126 iframe: 1,
4127 video: 1,
4128 audio: 1,
4129 canvas: 1,
4130 embed: 1,
4131 object: 1,
4132 source: 1,
4133 track: 1,
4134 meta: 1,
4135 link: 1,
4136 script: 1,
4137 style: 1,
4138 };
4139
4140 /**
4141 * Ceiling on how many elements may carry a generated-box rule.
4142 *
4143 * The rule table is keyed by id and an id is never reused, so on a long-lived
4144 * SPA — an admin screen, a filtered shop archive — it would otherwise grow for
4145 * as long as the tab stays open. Well above what a real page needs: the site
4146 * that motivated this layer uses eleven.
4147 */
4148 var DARKIFY_PSEUDO_RULE_LIMIT = 4000;
4149
4150 var darkify_pseudo_sheet_node = null;
4151 var darkify_pseudo_rules = Object.create(null);
4152 var darkify_pseudo_rule_count = 0;
4153 var darkify_pseudo_flush_scheduled = false;
4154 var darkify_pseudo_seq = 0;
4155
4156 /** The single stylesheet every generated-box rule is written into. */
4157 function darkify_pseudo_sheet() {
4158 if (darkify_pseudo_sheet_node && darkify_pseudo_sheet_node.parentNode) {
4159 return darkify_pseudo_sheet_node;
4160 }
4161
4162 var node = document.getElementById("darkify-pseudo-surfaces");
4163 if (!node) {
4164 node = document.createElement("style");
4165 node.id = "darkify-pseudo-surfaces";
4166 // Marked so the engine never walks into its own stylesheet.
4167 node.className = "darkify_ignore";
4168 (document.head || document.documentElement).appendChild(node);
4169 }
4170
4171 darkify_pseudo_sheet_node = node;
4172 return node;
4173 }
4174
4175 /**
4176 * Write the collected rules out, at most once per frame.
4177 *
4178 * Batched for the same reason the DOM walk is: one pass over a builder page
4179 * hands this hundreds of elements, and rewriting the sheet per element would
4180 * invalidate style for the whole document each time.
4181 */
4182 function darkify_flush_pseudo_rules(immediate) {
4183 if (!immediate) {
4184 if (darkify_pseudo_flush_scheduled) {
4185 return;
4186 }
4187 darkify_pseudo_flush_scheduled = true;
4188
4189 var run = function () {
4190 darkify_pseudo_flush_scheduled = false;
4191 darkify_flush_pseudo_rules(true);
4192 };
4193
4194 if (typeof requestAnimationFrame === "function") {
4195 requestAnimationFrame(run);
4196 } else {
4197 setTimeout(run, 0);
4198 }
4199 return;
4200 }
4201
4202 darkify_pseudo_flush_scheduled = false;
4203
4204 var css = "";
4205 for (var id in darkify_pseudo_rules) {
4206 css += darkify_pseudo_rules[id];
4207 }
4208
4209 darkify_pseudo_sheet().textContent = css;
4210 }
4211
4212 /** A colour that paints nothing, in any of the forms a computed style reports. */
4213 function darkify_is_transparent_color(value) {
4214 if (!value) {
4215 return true;
4216 }
4217 var color = darkify_parse_color(value);
4218 return !color || color.a === 0;
4219 }
4220
4221 /**
4222 * Whether a generated box is off limits.
4223 *
4224 * Free ships no pseudo-level allow/deny list, so nothing is excluded here — the
4225 * element-level exclusions are applied by the caller. Pro overrides this with
4226 * the pseudo halves of its Allowed / Disallowed Elements settings, which is why
4227 * it is a named function rather than inlined.
4228 */
4229 function darkify_pseudo_is_disallowed(element, pseudo) {
4230 return false;
4231 }
4232
4233 /**
4234 * The dark counterpart of a generated box's surface colour.
4235 *
4236 * On the frontend this is the ordinary transform, so a brand-coloured overlay
4237 * keeps its hue exactly as a brand-coloured element does.
4238 *
4239 * wp-admin takes the other branch, and deliberately. The adaptive layer is off
4240 * there (see darkify_adaptive_layer_enabled) because the admin's colours are
4241 * WordPress's own chrome rather than a design worth preserving, and an editor
4242 * full of preserved accents turns into one tinted wash. A generated box is held
4243 * to the same rule: it goes onto the neutral surface ramp whatever hue it
4244 * started with — which is also what the pseudo handler before this one did, so
4245 * admin screens keep the behaviour they had.
4246 */
4247 function darkify_pseudo_surface_color(value, element) {
4248 var control = darkify_is_control_sized(element);
4249
4250 if (darkify_adaptive_layer_enabled) {
4251 return darkify_transform_color(value, "background", {
4252 force: true,
4253 neutrals: true,
4254 control: control,
4255 });
4256 }
4257
4258 var color = darkify_parse_color(value);
4259 return color ? darkify_surface_color(darkify_rgb_to_hsl(color), control) : "";
4260 }
4261
4262 /**
4263 * The dark-mode declarations one generated box needs, or "" if it needs none.
4264 *
4265 * `ownerStyle` is the element's own computed style, passed in rather than read
4266 * again: it is the reference for deciding which of the box's colours are its
4267 * own and which are merely inherited.
4268 */
4269 function darkify_pseudo_declarations(element, style, ownerStyle) {
4270 var out = "";
4271
4272 var image = style.backgroundImage;
4273 var has_image = !!image && image !== "none";
4274 // A `url()` on a generated box is a picture, and pictures already belong to
4275 // darkify_darken_bg_image(), which writes this same pseudo through a rule of
4276 // its own. Recolouring the declaration here would be two writers fighting
4277 // over one property, so the picture case is left entirely to that one.
4278 var has_url = has_image && image.indexOf("url(") !== -1;
4279 var has_gradient = has_image && image.indexOf("gradient(") !== -1;
4280
4281 if (has_gradient && !has_url && darkify_gradient_mode !== "keep") {
4282 // A generated box carrying a scrim is the single most common way a builder
4283 // draws an overlay (`.elementor-background-overlay::before` and friends),
4284 // so the same rule the element path uses applies here: keep its alpha, and
4285 // write nothing at all when the scrim is already dark.
4286 if (darkify_is_scrim_gradient(image)) {
4287 var scrim_image = darkify_scrim_gradient(image);
4288 if (scrim_image !== image) {
4289 out += "background-image:" + scrim_image + " !important;";
4290 }
4291 } else {
4292 out +=
4293 "background-image:" +
4294 (darkify_gradient_mode === "flatten"
4295 ? "none"
4296 : darkify_recolor_gradient(image)) +
4297 " !important;";
4298 }
4299 }
4300
4301 if (!has_url) {
4302 var background = style.backgroundColor;
4303 if (!darkify_is_transparent_color(background)) {
4304 // Neutrals included, and forced past the Brand Colors setting, for the
4305 // same reason the gradient path includes them: a generated box has no
4306 // `darkify_style_*` class to fall back on, so a grey left alone here is
4307 // not deferred to the class repaint — it is simply left light.
4308 var surface = darkify_pseudo_surface_color(background, element);
4309 if (surface) {
4310 out += "background-color:" + surface + " !important;";
4311 }
4312 }
4313 }
4314
4315 // Only a colour the box states for itself. An inherited one belongs to the
4316 // element, which the class-based repaint has already handled — and because a
4317 // generated box inherits the *repainted* value, an inherited colour read here
4318 // is Darkify's own output. Deriving from that would drift it a little further
4319 // on every pass.
4320 var color = style.color;
4321 if (
4322 !darkify_is_transparent_color(color) &&
4323 (!ownerStyle || color !== ownerStyle.color)
4324 ) {
4325 var foreground = darkify_transform_color(color, "text", {
4326 force: true,
4327 neutrals: true,
4328 });
4329 if (foreground) {
4330 out += "color:" + foreground + " !important;";
4331 }
4332 }
4333
4334 // Borders are how a generated box draws a tooltip arrow, a caret or a rule.
4335 // Each side is taken separately because that is how those shapes are built:
4336 // one side carries the colour and the other three are transparent, and
4337 // painting the transparent ones would turn an arrow into a square.
4338 var sides = ["top", "right", "bottom", "left"];
4339 for (var i = 0; i < sides.length; i++) {
4340 var side = sides[i];
4341 if (style.getPropertyValue("border-" + side + "-style") === "none") {
4342 continue;
4343 }
4344 if (parseFloat(style.getPropertyValue("border-" + side + "-width")) === 0) {
4345 continue;
4346 }
4347
4348 var line = style.getPropertyValue("border-" + side + "-color");
4349 if (darkify_is_transparent_color(line)) {
4350 continue;
4351 }
4352 if (ownerStyle && line === ownerStyle.getPropertyValue("border-" + side + "-color")) {
4353 continue;
4354 }
4355
4356 var edge = darkify_transform_color(line, "border", {
4357 force: true,
4358 neutrals: true,
4359 });
4360 if (edge) {
4361 out += "border-" + side + "-color:" + edge + " !important;";
4362 }
4363 }
4364
4365 return out;
4366 }
4367
4368 /**
4369 * Bring an element's generated boxes into dark mode.
4370 *
4371 * Runs once per element and then marks it. Re-reading on a later pass would be
4372 * worse than useless: by then the box resolves through this layer's own rule,
4373 * so the "light-mode colour" it reports is a dark one, and each pass would walk
4374 * the colour further from the design. That is the same once-only contract the
4375 * gradient and shadow writers keep, for the same reason.
4376 *
4377 * Nothing has to be undone when dark mode goes off: the rules are written under
4378 * `.darkify_dark_mode_enabled`, so the cascade does it.
4379 */
4380 function darkify_process_pseudo_surfaces(element, ownerStyle) {
4381 if (DARKIFY_NO_GENERATED_BOX[element.nodeName.toLowerCase()]) {
4382 return;
4383 }
4384 if (element.hasAttribute("data-darkify-pseudo")) {
4385 return;
4386 }
4387 if (darkify_is_excluded_from_adaptation(element)) {
4388 return;
4389 }
4390 if (darkify_pseudo_rule_count >= DARKIFY_PSEUDO_RULE_LIMIT) {
4391 return;
4392 }
4393
4394 var rules = "";
4395
4396 for (var i = 0; i < DARKIFY_PSEUDO_NAMES.length; i++) {
4397 var pseudo = DARKIFY_PSEUDO_NAMES[i];
4398 if (darkify_pseudo_is_disallowed(element, pseudo)) {
4399 continue;
4400 }
4401
4402 var style;
4403 try {
4404 style = window.getComputedStyle(element, "::" + pseudo);
4405 } catch (e) {
4406 continue;
4407 }
4408
4409 // `content: none` means no box was generated, so there is nothing painted
4410 // to bring across — the overwhelmingly common case, and the cheapest test
4411 // available for it.
4412 if (!style || style.content === "none" || style.display === "none") {
4413 continue;
4414 }
4415
4416 var declarations = "";
4417 try {
4418 declarations = darkify_pseudo_declarations(element, style, ownerStyle);
4419 } catch (e) {
4420 declarations = "";
4421 }
4422 if (!declarations) {
4423 continue;
4424 }
4425
4426 rules +=
4427 '.darkify_dark_mode_enabled [data-darkify-pseudo="__ID__"]::' +
4428 pseudo +
4429 "{" +
4430 declarations +
4431 "}";
4432 }
4433
4434 if (!rules) {
4435 return;
4436 }
4437
4438 // The id is minted only once something is actually going to be written, so an
4439 // ordinary element — a clearfix, a list marker, anything whose generated box
4440 // paints nothing — leaves no attribute behind and costs no rule.
4441 var id = "p" + ++darkify_pseudo_seq;
4442 element.setAttribute("data-darkify-pseudo", id);
4443
4444 darkify_pseudo_rules[id] = rules.split("__ID__").join(id);
4445 darkify_pseudo_rule_count++;
4446 darkify_flush_pseudo_rules(false);
4447 }
4448
4449 /**
4450 * Kept for the init path, which calls it once the first walk is through.
4451 *
4452 * The rules are now written as each element is reached rather than collected
4453 * and applied in a second sweep — that is what makes AJAX and builder-injected
4454 * content work, since nothing re-runs a sweep for it — so all this has left to
4455 * do is make sure the last batch is on the page.
4456 */
4457 function darkify_apply_pseudo_bg_styles() {
4458 darkify_flush_pseudo_rules(true);
4459 }
4460
4461 function darkify_fix_background_color_alpha(element) {
4462 if (
4463 document
4464 .getElementsByTagName("html")[0]
4465 .classList.contains("darkify_dark_mode_enabled")
4466 ) {
4467 if (element.hasAttribute("data-darkify_alpha_bg")) {
4468 var alphaValue = element.dataset.darkify_alpha_bg
4469 .replace("rgba(", "")
4470 .replace(")", "")
4471 .split(",")[3]
4472 .trim();
4473 var backgroundColor = window.getComputedStyle(
4474 element,
4475 null,
4476 ).backgroundColor;
4477
4478 if (!backgroundColor.includes("rgba")) {
4479 element.style.setProperty(
4480 "background-color",
4481 backgroundColor
4482 .replace(")", ", " + alphaValue + ")")
4483 .replace("rgb", "rgba"),
4484 "important",
4485 );
4486 }
4487 }
4488 } else if (element.hasAttribute("data-darkify_alpha_bg")) {
4489 element.style.backgroundColor = "";
4490 }
4491 }
4492
4493 function darkify_implement_secondary_bg() {
4494 let maxAreaElement = null;
4495 let maxArea = 0;
4496
4497 const elements = document.querySelectorAll(
4498 "* :not(head, title, link, meta, script, style, defs, filter)",
4499 );
4500
4501 for (let i = 0; i < elements.length; i++) {
4502 const element = elements[i];
4503 if (element.hasAttribute("data-darkify_secondary_bg_finder")) {
4504 const secondaryBgColor = element.dataset.darkify_secondary_bg_finder;
4505 if (
4506 secondaryBgColor !== "transparent" &&
4507 secondaryBgColor !== "rgba(0, 0, 0, 0)"
4508 ) {
4509 const boundingRect = element.getBoundingClientRect();
4510 const area = boundingRect.width * boundingRect.height;
4511 if (area > maxArea) {
4512 maxArea = area;
4513 maxAreaElement = secondaryBgColor;
4514 }
4515 }
4516 }
4517 }
4518
4519 for (let i = 0; i < elements.length; i++) {
4520 const element = elements[i];
4521 if (element.hasAttribute("data-darkify_secondary_bg_finder")) {
4522 if (
4523 element.classList.contains("darkify_style_all") ||
4524 element.classList.contains("darkify_style_bg_txt") ||
4525 element.classList.contains("darkify_style_bg_border") ||
4526 element.classList.contains("darkify_style_bg")
4527 ) {
4528 const isDifferentSecondaryBg =
4529 maxAreaElement !== element.dataset.darkify_secondary_bg_finder;
4530 if (isDifferentSecondaryBg) {
4531 element.classList.add("darkify_style_secondary_bg");
4532 }
4533 }
4534 delete element.dataset.darkify_secondary_bg_finder;
4535 }
4536 }
4537
4538 darkify_secondary_bg_color = maxAreaElement;
4539 }
4540
4541 function darkify_recheck_on_css_loaded_later() {
4542 document
4543 .querySelectorAll(
4544 ".darkify_style_txt_border, .darkify_style_txt, .darkify_style_border",
4545 )
4546 .forEach(function (element) {
4547 const computedStyle = window.getComputedStyle(element, null);
4548 const backgroundColor = computedStyle.backgroundColor;
4549 if (
4550 backgroundColor !== "rgba(0, 0, 0, 0)" &&
4551 backgroundColor !== "rgba(255, 255, 255, 0)"
4552 ) {
4553 darkify_process_element(element);
4554 }
4555 });
4556 }
4557
4558 function darkify_check_preloading() {
4559 let isPreloaded = false;
4560 const lastState = localStorage.darkify_last_state
4561 ? localStorage.darkify_last_state
4562 : "not_set";
4563 const adminPanelLastState = localStorage.darkify_admin_panel_last_state
4564 ? localStorage.darkify_admin_panel_last_state
4565 : "not_set";
4566
4567 if (darkify_is_this_admin_panel === "1") {
4568 if (adminPanelLastState === "1") {
4569 isPreloaded = true;
4570 }
4571 } else {
4572 if (lastState === "1" || lastState === "0") {
4573 if (lastState === "1") {
4574 isPreloaded = true;
4575 }
4576 } else {
4577 if (darkify_enable_default_dark_mode === "1") {
4578 isPreloaded = true;
4579 }
4580 if (darkify_enable_time_based_dark === "1") {
4581 const currentDate = new Date();
4582 const darkStart = new Date();
4583 const darkStop = new Date();
4584 darkStart.setHours(
4585 parseInt(darkify_time_based_dark_start.split(":")[0]),
4586 );
4587 darkStart.setMinutes(
4588 parseInt(darkify_time_based_dark_start.split(":")[1]),
4589 );
4590 darkStop.setHours(parseInt(darkify_time_based_dark_stop.split(":")[0]));
4591 darkStop.setMinutes(
4592 parseInt(darkify_time_based_dark_stop.split(":")[1]),
4593 );
4594
4595 if (
4596 parseInt(darkify_time_based_dark_stop.split(":")[0]) >=
4597 parseInt(darkify_time_based_dark_start.split(":")[0])
4598 ) {
4599 if (
4600 currentDate.getTime() > darkStart.getTime() &&
4601 currentDate.getTime() < darkStop.getTime()
4602 ) {
4603 isPreloaded = true;
4604 }
4605 } else if (currentDate.getHours() > 12) {
4606 if (
4607 currentDate.getTime() > darkStart.getTime() &&
4608 currentDate.getTime() > darkStop.getTime()
4609 ) {
4610 isPreloaded = true;
4611 }
4612 } else if (
4613 currentDate.getTime() < darkStart.getTime() &&
4614 currentDate.getTime() < darkStop.getTime()
4615 ) {
4616 isPreloaded = true;
4617 }
4618 }
4619 }
4620 }
4621
4622 if (
4623 darkify_is_this_admin_panel === "0" &&
4624 darkify_enable_os_aware === "1" &&
4625 window.matchMedia &&
4626 window.matchMedia("(prefers-color-scheme: dark)").matches &&
4627 lastState !== "1" &&
4628 lastState !== "0"
4629 ) {
4630 isPreloaded = true;
4631 }
4632
4633 return isPreloaded;
4634 }
4635
4636 /* ── Self-theming app detection ───────────────────────────────────────────
4637 *
4638 * Darkify darkens a page by reading each element's colours and stamping an
4639 * `!important` override on top. That is right for classic admin markup, which
4640 * takes its colours from the cascade — but a modern admin app (React, Vue, …)
4641 * built on design tokens does not. It ships its own complete dark theme keyed
4642 * on `dark` on <html>, which Darkify now sets, so by the time the engine walks
4643 * the page that app has ALREADY themed itself. Repainting it then does not help;
4644 * it flattens the palette, collapsing cards, popovers and page background into
4645 * one flat grey and fighting a theme that was already correct.
4646 *
4647 * So the engine needs to recognise "this subtree already handled it" — without
4648 * knowing anything about which plugin drew it. The signal used here is the same
4649 * convention that made the app dark in the first place:
4650 *
4651 * 1. The page ships a stylesheet rule that references the `dark` class AND
4652 * declares custom properties — i.e. a `.dark { --background: … }` token
4653 * block. That is the fingerprint of a class-switched token theme
4654 * (Tailwind's class strategy, shadcn/ui, and Darkify's own React admin).
4655 * 2. An element whose resolved colour IS one of those token values is being
4656 * painted by that theme, so it is left alone, along with its subtree.
4657 *
4658 * Both halves are structural, not nominal: no plugin name, no container id, no
4659 * DOM-shape assumption. A page with no such token block yields an empty set and
4660 * every element takes exactly the path it took before, so classic admin screens
4661 * are untouched.
4662 */
4663
4664 var darkify_dark_token_names = null;
4665 var darkify_dark_token_colors = null;
4666 var darkify_dark_token_signature = null;
4667
4668 /**
4669 * Names of the custom properties a `dark`-keyed rule declares, read once from
4670 * the page's own stylesheets.
4671 *
4672 * A rule only counts when it BOTH references the class and declares `--*`
4673 * properties. That pairing is what separates a theme's token block from an
4674 * ordinary dark variant utility (Tailwind compiles `dark:bg-card` to a selector
4675 * that also mentions the class but only sets `background-color`), and it is why
4676 * the class-name test does not need to be clever.
4677 */
4678 function darkify_collect_dark_token_names() {
4679 if (darkify_dark_token_names !== null) {
4680 return darkify_dark_token_names;
4681 }
4682
4683 var names = {};
4684 // `.dark` not followed by a word character or hyphen, so Darkify's own
4685 // `.darkify_*` classes (and any `.dark-theme` of someone else's) don't count.
4686 var dark_class = /\.dark(?![\w-])/;
4687
4688 function scan(rules) {
4689 for (var i = 0; i < rules.length; i++) {
4690 var rule = rules[i];
4691
4692 if (
4693 rule.selectorText &&
4694 rule.style &&
4695 dark_class.test(rule.selectorText)
4696 ) {
4697 for (var j = 0; j < rule.style.length; j++) {
4698 var prop = rule.style[j];
4699 if (prop.charAt(0) === "-" && prop.charAt(1) === "-") {
4700 names[prop] = true;
4701 }
4702 }
4703 }
4704
4705 // @media / @supports nest their own rule lists — and so, since CSS
4706 // Nesting shipped, does an ordinary style rule, which now exposes an
4707 // empty `cssRules` of its own. Recursing on existence rather than on
4708 // length therefore swallowed EVERY top-level rule (each one looked like a
4709 // group with no children), which is why this found nothing at all.
4710 if (rule.cssRules && rule.cssRules.length) {
4711 scan(rule.cssRules);
4712 }
4713 }
4714 }
4715
4716 var sheets = document.styleSheets;
4717 for (var s = 0; s < sheets.length; s++) {
4718 try {
4719 if (sheets[s].cssRules) {
4720 scan(sheets[s].cssRules);
4721 }
4722 } catch (e) {
4723 // Cross-origin stylesheet — unreadable by design, and never one of ours.
4724 }
4725 }
4726
4727 darkify_dark_token_names = Object.keys(names);
4728 return darkify_dark_token_names;
4729 }
4730
4731 /**
4732 * Those tokens resolved to real colours, as a lookup keyed by computed value.
4733 *
4734 * Custom properties compute to their raw token text (`oklch(…)`, `#0c1116`),
4735 * which never string-matches the `rgb(…)` an element reports, so each one is
4736 * resolved through a probe element and compared in that normalised form. The
4737 * probe lives in the document so it inherits the same theme the app sees.
4738 *
4739 * Cached against the root's class list: that is what carries `dark` and the
4740 * palette classes, so the cache drops exactly when the resolved values change.
4741 */
4742 function darkify_dark_theme_colors() {
4743 var names = darkify_collect_dark_token_names();
4744 if (names.length === 0 || !document.body) {
4745 return null;
4746 }
4747
4748 var signature = document.documentElement.className;
4749 if (darkify_dark_token_colors !== null && darkify_dark_token_signature === signature) {
4750 return darkify_dark_token_colors;
4751 }
4752
4753 var probe = document.createElement("span");
4754 // Excluded from the engine and from layout; purely a colour resolver.
4755 probe.className = "darkify_ignore";
4756 probe.style.cssText =
4757 "position:absolute;left:-9999px;top:-9999px;width:0;height:0;visibility:hidden;pointer-events:none;";
4758 document.body.appendChild(probe);
4759
4760 var colors = {};
4761 for (var i = 0; i < names.length; i++) {
4762 probe.style.color = "";
4763 probe.style.color = "var(" + names[i] + ")";
4764 var resolved = window.getComputedStyle(probe).color;
4765 // Tokens that aren't colours (radii, spacing) simply don't resolve to one.
4766 if (resolved && resolved.indexOf("rgb") === 0) {
4767 colors[resolved] = true;
4768 }
4769 }
4770
4771 probe.parentNode.removeChild(probe);
4772
4773 darkify_dark_token_colors = colors;
4774 darkify_dark_token_signature = signature;
4775 return colors;
4776 }
4777
4778 /** Whether self-theming detection should run at all on this page. */
4779 function darkify_self_theming_active() {
4780 return (
4781 typeof darkify_is_this_admin_panel !== "undefined" &&
4782 darkify_is_this_admin_panel === "1" &&
4783 // Only meaningful while the class is on: with it off the tokens resolve to
4784 // the app's LIGHT values, and matching those would skip the very elements
4785 // that still need darkening.
4786 document.documentElement.classList.contains("dark")
4787 );
4788 }
4789
4790 /** Cheap ancestor check — an already-identified subtree is skipped wholesale. */
4791 function darkify_in_self_themed_subtree(element) {
4792 return (
4793 darkify_self_theming_active() &&
4794 !!element.closest &&
4795 !!element.closest(".darkify_self_themed")
4796 );
4797 }
4798
4799 /**
4800 * Mark `element` when its own colours come from the page's dark token theme.
4801 *
4802 * Marking the element rather than testing every node keeps this O(1) per
4803 * subtree: document order means the outermost themed container is reached
4804 * first, and everything below it then short-circuits on the ancestor check —
4805 * including nodes React mounts later, which is what makes SPA route changes and
4806 * late-rendered components work without re-detection.
4807 */
4808 function darkify_mark_if_self_themed(element, computedStyle) {
4809 if (!darkify_self_theming_active()) {
4810 return false;
4811 }
4812
4813 // Never hand the whole document over: <html>/<body> belong to wp-admin, and
4814 // the engine still owns the page backdrop behind any app.
4815 var nodeName = element.nodeName.toLowerCase();
4816 if (nodeName === "html" || nodeName === "body") {
4817 return false;
4818 }
4819
4820 var colors = darkify_dark_theme_colors();
4821 if (!colors) {
4822 return false;
4823 }
4824
4825 if (colors[computedStyle.color] || colors[computedStyle.backgroundColor]) {
4826 element.classList.add("darkify_self_themed");
4827 return true;
4828 }
4829
4830 return false;
4831 }
4832
4833 var DARKIFY_BORDER_SIDES = ["top", "right", "bottom", "left"];
4834
4835 /** Whether one border side actually paints a line. */
4836 function darkify_border_side_paints(style, side) {
4837 var line = style.getPropertyValue("border-" + side + "-style");
4838 if (!line || line === "none" || line === "hidden") {
4839 return false;
4840 }
4841 if (parseFloat(style.getPropertyValue("border-" + side + "-width")) <= 0) {
4842 return false;
4843 }
4844 var color = darkify_parse_color(
4845 style.getPropertyValue("border-" + side + "-color"),
4846 );
4847 return !!color && color.a !== 0;
4848 }
4849
4850 /**
4851 * The element's border colour for classification, and the transparent-side
4852 * markers that go with it.
4853 *
4854 * The shorthand `borderColor` cannot answer the question the classifier is
4855 * actually asking. When the four sides differ it returns a list — Woo's product
4856 * card reports `"rgb(51,51,51) rgba(0,0,0,0) rgba(0,0,0,0)"` — which is not
4857 * equal to the transparent sentinel, so the element counted as bordered and was
4858 * given a border class. Every side then got painted, including the three that
4859 * paint nothing, and the card grew a box in dark mode that it does not have in
4860 * light.
4861 *
4862 * Two things are wrong there and both are fixed here. A side is only a border
4863 * if it has a style, a width AND a colour; measuring all four says whether this
4864 * element has any border at all. And a side that has a width but no colour is
4865 * spacing — the `border: 8px solid transparent` idiom — so it is marked, and
4866 * the rules in client_main.css pin it back to transparent whichever colour rule
4867 * ends up matching.
4868 *
4869 * @return {string} A painted side's colour, or the transparent sentinel when
4870 * the design draws no border at all.
4871 */
4872 function darkify_border_color_for_classification(element, style) {
4873 var painted = "";
4874
4875 for (var i = 0; i < DARKIFY_BORDER_SIDES.length; i++) {
4876 var side = DARKIFY_BORDER_SIDES[i];
4877 var marker = "darkify_border_keep_" + side;
4878
4879 if (darkify_border_side_paints(style, side)) {
4880 if (!painted) {
4881 painted = style.getPropertyValue("border-" + side + "-color");
4882 }
4883 element.classList.remove(marker);
4884 continue;
4885 }
4886
4887 // Width without colour is spacing, and only spacing needs protecting — a
4888 // side with no width paints nothing whatever colour it is given.
4889 var occupies =
4890 parseFloat(style.getPropertyValue("border-" + side + "-width")) > 0;
4891 if (occupies) {
4892 element.classList.add(marker);
4893 } else {
4894 element.classList.remove(marker);
4895 }
4896 }
4897
4898 return painted || "rgba(0, 0, 0, 0)";
4899 }
4900
4901 /**
4902 * Whether a colour this element reports may be a frame of an animation rather
4903 * than a value the design chose.
4904 *
4905 * A transition makes `getComputedStyle` report the interpolated colour of the
4906 * moment. That matters here because the transition is usually Darkify's own
4907 * doing: stamping a class repaints the element, the theme animates the change,
4908 * and a walk that reaches the element while that is running reads a colour
4909 * halfway between the design's and the plugin's.
4910 *
4911 * Interpolating a transparent background to an opaque one passes through
4912 * `rgba(45, 45, 45, 0.62)` — partly transparent, and indistinguishable by value
4913 * from a translucent surface the designer meant. Recorded as one, it was then
4914 * re-applied as the element's "own" alpha, and a `<select>` the design left
4915 * open kept a half-see-through grey box. The alpha landed somewhere different
4916 * on every load, which is the signature of reading an animation.
4917 *
4918 * Nothing here is fixable by looking harder at the value, so the value is not
4919 * trusted at all when a transition covers it.
4920 */
4921 function darkify_color_may_be_animating(style) {
4922 var duration = style.transitionDuration;
4923 if (!duration || /^(0s)(,\s*0s)*$/.test(duration.trim())) {
4924 return false;
4925 }
4926
4927 var props = style.transitionProperty || "";
4928 return (
4929 props.indexOf("all") !== -1 ||
4930 props.indexOf("background") !== -1 ||
4931 props.indexOf("color") !== -1
4932 );
4933 }
4934
4935 /* ==========================================================================
4936 Reading stable colours
4937 --------------------------------------------------------------------------
4938 The engine decides what an element is by reading its computed colours. A
4939 transition breaks that read: `getComputedStyle` reports the interpolated
4940 colour of the current frame, so what comes back is a point somewhere between
4941 two values rather than either of them.
4942
4943 The transition is usually Darkify's own. Stamping a `darkify_style_*` class
4944 repaints the element, the theme animates the repaint, and the next pass — the
4945 class-change observer re-processing that very element — arrives mid-animation.
4946 It strips the classes to read the design's colours again, but the strip
4947 animates too, so the value it reads is still partly the plugin's.
4948
4949 Everything downstream then inherits that. A transparent background
4950 interpolating toward an opaque one reports `rgba(45, 45, 45, 0.62)`, which is
4951 indistinguishable by value from a translucent surface someone designed, so it
4952 was recorded and re-applied as one: a `<select>` the design left open kept a
4953 half-see-through box, at a different opacity on every load.
4954
4955 The cure is to take the element out of transition for the length of its own
4956 pass, which cancels anything running and makes every read land on a settled
4957 value. Deliberately per element and inline: a stylesheet that switched all
4958 transitions off around the whole walk did the same job but invalidated style
4959 for the entire document twice per pass, and style recalculation rose by about
4960 a third. `transition-property` is not inherited, so writing it here costs one
4961 element's recalculation — work this pass is doing anyway.
4962 ========================================================================== */
4963
4964 /**
4965 * SVG icons a design paints through `background-image`.
4966 *
4967 * A `<select>` gets its dropdown chevron this way, and so do accordions,
4968 * checkboxes, radio marks, pagination arrows and search buttons: an inline
4969 * `data:image/svg+xml` URI with the colour written into the markup, almost
4970 * always a dark grey chosen to read on a light page. Nothing in the engine
4971 * could touch it. The colour lives inside a URL, not in a CSS property, so no
4972 * class and no inline declaration reaches it, and the icon stayed `#333333` on
4973 * a dark background — present, correctly positioned, and invisible.
4974 *
4975 * Worse, it used to be handed to darkify_darken_bg_image(), which exists to
4976 * stop a photograph glaring on a dark page. An icon is the opposite case: it is
4977 * foreground, it is already too dark, and dimming it 60% finishes the job of
4978 * hiding it. An inline SVG is never a photograph, so it takes this path instead.
4979 *
4980 * The colours go through the same darkify_transform_color() as everything else,
4981 * in the `icon` role, so they land at the palette's foreground level and a
4982 * coloured icon keeps its hue.
4983 */
4984 // Quoting matters here and the obvious character class gets it wrong. The
4985 // markup inside these URIs is full of unencoded single quotes
4986 // (`class='ast-arrow-svg'`), so a pattern that stops at the first quote of
4987 // either kind matches nothing at all — which is exactly how the first version
4988 // of this silently did nothing. Each quoting form gets its own alternative, and
4989 // each one only terminates on its own delimiter.
4990 var DARKIFY_SVG_DATA_URI =
4991 /url\(\s*(?:"(data:image\/svg\+xml[^"]*)"|'(data:image\/svg\+xml[^']*)'|(data:image\/svg\+xml[^)\s]*))\s*\)/gi;
4992 var DARKIFY_SVG_PAINT =
4993 /(fill|stroke|stop-color|flood-color)\s*[:=]\s*(['"]?)(#[0-9a-fA-F]{3,8}|rgba?\([^)]*\))\2/g;
4994
4995 /** Cap on the markup this will parse — artwork is not an icon. */
4996 var DARKIFY_SVG_MAX_LENGTH = 8000;
4997
4998 function darkify_recolor_svg_background(image) {
4999 return String(image).replace(
5000 DARKIFY_SVG_DATA_URI,
5001 function (whole, doubled, singled, bare) {
5002 var uri = doubled || singled || bare;
5003 var quote = doubled ? '"' : singled ? "'" : "";
5004 var comma = uri.indexOf(",");
5005 if (comma === -1) {
5006 return whole;
5007 }
5008
5009 var head = uri.slice(0, comma + 1);
5010 // Base64 payloads are not worth decoding for this, and an icon shipped
5011 // that way is rare enough not to matter.
5012 if (head.indexOf("base64") !== -1) {
5013 return whole;
5014 }
5015
5016 var markup;
5017 try {
5018 markup = decodeURIComponent(uri.slice(comma + 1));
5019 } catch (e) {
5020 return whole;
5021 }
5022 if (markup.length > DARKIFY_SVG_MAX_LENGTH) {
5023 return whole;
5024 }
5025
5026 var changed = false;
5027 var next = markup.replace(
5028 DARKIFY_SVG_PAINT,
5029 function (match, prop, q, color) {
5030 var mapped = "";
5031 try {
5032 mapped = darkify_transform_color(color, "icon", {
5033 force: true,
5034 neutrals: true,
5035 });
5036 } catch (e) {
5037 mapped = "";
5038 }
5039 if (!mapped) {
5040 return match;
5041 }
5042 changed = true;
5043 // Rebuilt around the colour so the original separator
5044 // (`fill="x"` vs `fill:x`) survives untouched.
5045 var at = match.indexOf(color);
5046 return match.slice(0, at) + mapped + match.slice(at + color.length);
5047 },
5048 );
5049
5050 if (!changed) {
5051 return whole;
5052 }
5053
5054 return "url(" + quote + head + encodeURIComponent(next) + quote + ")";
5055 },
5056 );
5057 }
5058
5059 var darkify_icon_seq = 0;
5060
5061 /**
5062 * Give an element's SVG background icons a dark-mode colour.
5063 *
5064 * Written as a rule in the shared stylesheet rather than inline, for the reason
5065 * the generated-box layer uses it: the rule is gated on
5066 * `darkify_dark_mode_enabled`, so switching back to light unpaints it with no
5067 * restore pass and nothing to remember.
5068 */
5069 function darkify_process_icon_background(element, style) {
5070 if (element.hasAttribute("data-darkify-icon")) {
5071 return;
5072 }
5073 if (darkify_pseudo_rule_count >= DARKIFY_PSEUDO_RULE_LIMIT) {
5074 return;
5075 }
5076
5077 var image = style.backgroundImage;
5078 var next = darkify_recolor_svg_background(image);
5079 if (next === image) {
5080 return;
5081 }
5082
5083 var id = "i" + ++darkify_icon_seq;
5084 element.setAttribute("data-darkify-icon", id);
5085 darkify_pseudo_rules[id] =
5086 '.darkify_dark_mode_enabled [data-darkify-icon="' +
5087 id +
5088 '"]{background-image:' +
5089 next +
5090 " !important;}";
5091 darkify_pseudo_rule_count++;
5092 darkify_flush_pseudo_rules(false);
5093 }
5094
5095 /**
5096 * Take the page out of transition while a pass reads and repaints.
5097 *
5098 * Why this exists is unchanged: a running colour transition makes
5099 * `getComputedStyle` report the interpolated colour rather than the settled
5100 * one, so an element caught mid-animation is classified on a colour that
5101 * belongs to neither state. Suppressing transitions makes the read truthful.
5102 *
5103 * How it is done is what changed, and it is the single most expensive line the
5104 * old engine had. It used to write `transition-property: none` *inline on each
5105 * element* and then immediately call `getComputedStyle` on that same element.
5106 * A style write followed by a style read is a forced synchronous recalculation,
5107 * and doing it per element means the browser recalculates styles once per
5108 * element instead of once per pass — 2,806 forced recalcs on the measured page,
5109 * which is where its 1.34 seconds of style recalculation came from. It also
5110 * wrote and removed two inline properties per element for a value that is
5111 * identical for all of them.
5112 *
5113 * One class on `<html>`, applied before the pass and removed after it, produces
5114 * exactly the same suppression for one style invalidation total. The rule lives
5115 * in client_main.css next to the engine's other structural rules.
5116 */
5117 function darkify_begin_pass() {
5118 document.documentElement.classList.add("darkify_suspend_transitions");
5119 }
5120
5121 function darkify_end_pass() {
5122 document.documentElement.classList.remove("darkify_suspend_transitions");
5123 }
5124
5125 /**
5126 * Process one element, with its own transitions suppressed while it is read.
5127 *
5128 * The read has to land on a settled colour (see "Reading stable colours"), and
5129 * outside the two whole-document passes that suppression is scoped to the one
5130 * element being read rather than to the page. Scope is the whole point: an
5131 * incremental pass fires on any class change anywhere, and a document-wide
5132 * suppression during one cancels transitions on every other element too, so a
5133 * page whose menus, sliders and hover effects animate through class changes
5134 * lost those animations to passes that were looking at something else.
5135 *
5136 * `transition-property` is not inherited, so the class costs this element's own
5137 * style recalculation — work the pass is doing anyway when it reads it — and
5138 * nothing for the rest of the document.
5139 */
5140 function darkify_process_element(element) {
5141 darkify_debug_count("elements_processed");
5142
5143 if (darkify_pass_is_whole_document) {
5144 darkify_process_element_settled(element);
5145 return;
5146 }
5147
5148 const stamped = !element.classList.contains("darkify_no_transition");
5149 if (stamped) {
5150 element.classList.add("darkify_no_transition");
5151 }
5152 try {
5153 darkify_process_element_settled(element);
5154 } finally {
5155 if (stamped) {
5156 element.classList.remove("darkify_no_transition");
5157 }
5158 }
5159 }
5160
5161 function darkify_process_element_settled(element) {
5162 // Before any style read: everything under an app that themes itself is left
5163 // exactly as that app painted it.
5164 if (darkify_in_self_themed_subtree(element)) {
5165 return;
5166 }
5167
5168 var computedStyle = window.getComputedStyle(element, null);
5169 var old_transition = "";
5170
5171 // if (computedStyle.transition !== "all 0s ease 0s") {
5172 // old_transition = computedStyle.transition;
5173 // // element.style.setProperty("transition", "none");
5174 // }
5175
5176 if (
5177 element.classList.contains("darkify_style_all") ||
5178 element.classList.contains("darkify_style_bg_txt") ||
5179 element.classList.contains("darkify_style_bg_border") ||
5180 element.classList.contains("darkify_style_txt_border") ||
5181 element.classList.contains("darkify_style_bg") ||
5182 element.classList.contains("darkify_style_txt") ||
5183 element.classList.contains("darkify_style_border") ||
5184 element.classList.contains("darkify_style_secondary_bg")
5185 ) {
5186 element.classList.remove("darkify_style_all");
5187 element.classList.remove("darkify_style_bg_txt");
5188 element.classList.remove("darkify_style_bg_border");
5189 element.classList.remove("darkify_style_txt_border");
5190 element.classList.remove("darkify_style_bg");
5191 element.classList.remove("darkify_style_txt");
5192 element.classList.remove("darkify_style_border");
5193 element.classList.remove("darkify_style_secondary_bg");
5194 }
5195
5196 // Before anything reads `computedStyle` below: drop the translucency-fix
5197 // background this pass stamped on a previous visit. It writes
5198 // `background-color` inline, so leaving it in place would let Darkify's own
5199 // output be read back below as the design's alpha and re-applied on top of
5200 // itself — the element fading a little further on every pass.
5201 if (element.hasAttribute("data-darkify_alpha_bg")) {
5202 element.style.removeProperty("background-color");
5203 element.removeAttribute("data-darkify_alpha_bg");
5204 }
5205
5206 // The deterministic writers' inline declarations have to come off for the
5207 // same reason: they carry `!important`, so they'd survive the class reset
5208 // above and answer for the element below, where everything is classified
5209 // from `computedStyle`. Each one re-applies at the end of this function (or
5210 // is left off, if the design no longer needs it), so nothing is lost.
5211 if (element.classList.contains("darkify_gradient_flattened")) {
5212 darkify_restore_inline(element, "darkifyGradientPrev", [
5213 "background-image",
5214 "background-color",
5215 ]);
5216 element.classList.remove("darkify_gradient_flattened");
5217 }
5218 if (element.classList.contains("darkify_shadow_neutralized")) {
5219 darkify_restore_inline(element, "darkifyShadowPrev", DARKIFY_SHADOW_PROPS);
5220 element.classList.remove("darkify_shadow_neutralized");
5221 }
5222 if (element.classList.contains("darkify_icon_recoloured")) {
5223 darkify_restore_inline(element, "darkifyIconPrev", ["color", "fill", "stroke"]);
5224 element.classList.remove("darkify_icon_recoloured");
5225 }
5226 if (element.classList.contains("darkify_overlay_flattened")) {
5227 darkify_restore_inline(element, "darkifyOverlayPrev", ["background-color"]);
5228 element.classList.remove("darkify_overlay_flattened");
5229 }
5230 if (element.classList.contains("darkify_color_overridden")) {
5231 darkify_restore_inline(element, "darkifyOverridePrev", DARKIFY_OVERRIDE_PROPS);
5232 element.classList.remove("darkify_color_overridden");
5233 }
5234
5235 // Same reasoning for the transparent-side markers, and they would fail in a
5236 // nastier way if left on: each one forces its side to `transparent`, which is
5237 // exactly the state that causes it to be applied. Measured again while still
5238 // marked, every marked side re-measures as transparent and the marker becomes
5239 // permanent — including on an element whose border the design does draw.
5240 // Clearing them first means each pass measures the design, not the last pass.
5241 for (
5242 var darkify_side_index = 0;
5243 darkify_side_index < DARKIFY_BORDER_SIDES.length;
5244 darkify_side_index++
5245 ) {
5246 element.classList.remove(
5247 "darkify_border_keep_" + DARKIFY_BORDER_SIDES[darkify_side_index],
5248 );
5249 }
5250
5251
5252 var nodeName = element.nodeName.toLowerCase();
5253 var backgroundColor = computedStyle.backgroundColor;
5254 var color = computedStyle.color;
5255 var borderColor = darkify_border_color_for_classification(
5256 element,
5257 computedStyle,
5258 );
5259 var backgroundImage = computedStyle.backgroundImage;
5260
5261 // Captured once, before the class-based repaint below can reset
5262 // `background-image` to `none` via the `background` shorthand — a later
5263 // pass (a toggle re-visiting an already-repainted element) would otherwise
5264 // never be able to recover the gradient to recolour, or tell a handled one
5265 // apart from a page that never had one. See darkify_process_gradient().
5266 if (
5267 !element.dataset.darkifyGradientSrc &&
5268 backgroundImage &&
5269 backgroundImage.indexOf("gradient(") !== -1 &&
5270 backgroundImage.indexOf("url(") === -1
5271 ) {
5272 element.dataset.darkifyGradientSrc = backgroundImage;
5273 }
5274
5275 // Same idea, for the same reason, for darkify_process_color_overrides():
5276 // by the time that writer runs, the class-based repaint below may already
5277 // have painted this element's own background/text/border colours, and it
5278 // needs the design's own values to match overrides against, not its own
5279 // eventual output. Gated on `darkify_has_color_overrides` — with no
5280 // overrides configured (the default) this costs nothing.
5281 if (darkify_has_color_overrides && !element.dataset.darkifyOverrideSrc) {
5282 element.dataset.darkifyOverrideSrc = JSON.stringify({
5283 "background-color": backgroundColor,
5284 color: color,
5285 "border-top-color": computedStyle.borderTopColor,
5286 "border-right-color": computedStyle.borderRightColor,
5287 "border-bottom-color": computedStyle.borderBottomColor,
5288 "border-left-color": computedStyle.borderLeftColor,
5289 });
5290 }
5291
5292 if (
5293 nodeName === "body" &&
5294 (backgroundColor === "rgba(0, 0, 0, 0)" ||
5295 backgroundColor === "rgba(255, 255, 255, 0)")
5296 ) {
5297 element.style.setProperty("background-color", "rgb(255, 255, 255)");
5298 backgroundColor = window.getComputedStyle(element, null).backgroundColor;
5299 }
5300
5301 if (darkify_disallowed_elements.length > 0) {
5302 if (element.matches(darkify_disallowed_elements)) {
5303 // Two different things end up on this list. The user's own Disallowed
5304 // Elements mean "leave this alone", and are left alone. The rest are
5305 // built-in builder exclusions (`.elementor-background-overlay` and
5306 // friends) that exist because painting them a flat dark colour would
5307 // destroy what they are — an overlay covering its own image. Those still
5308 // get their generated-box (`::before`/`::after`) surfaces handled below,
5309 // just not the class-based repaint.
5310 var user_disallowed = false;
5311 if (darkify_disallowed_elements_raw.length > 0) {
5312 try {
5313 user_disallowed = element.matches(darkify_disallowed_elements_raw);
5314 } catch (e) {
5315 user_disallowed = false;
5316 }
5317 }
5318
5319 if (!user_disallowed) {
5320 // Generated boxes for the same reason: a builder's overlay is exactly
5321 // the element most likely to carry its surface on a `::before`, and
5322 // skipping it here is what let those sections stay light.
5323 darkify_process_pseudo_surfaces(element, computedStyle);
5324 darkify_process_deterministic_fixes(element);
5325 }
5326
5327 // if (old_transition !== "") {
5328 // element.style.setProperty("transition", old_transition);
5329 // }
5330 // element.classList.remove("darkify_processed");
5331 return;
5332 }
5333 }
5334
5335 // The element's colours resolve to the page's own dark tokens, so its theme
5336 // has already dressed it — and its whole subtree with it.
5337 if (darkify_mark_if_self_themed(element, computedStyle)) {
5338 return;
5339 }
5340
5341 var has_background_img_url = false;
5342 if (backgroundImage !== "none" && backgroundImage.includes("url")) {
5343 has_background_img_url = true;
5344 if (backgroundImage.indexOf("data:image/svg+xml") !== -1) {
5345 // An inline SVG is an icon, never a photograph — it needs recolouring to
5346 // stay legible, and the darkener would do the exact opposite.
5347 darkify_process_icon_background(element, computedStyle);
5348 } else if (darkify_enable_bg_image_darken === "1") {
5349 darkify_darken_bg_image(element, darken_level);
5350 }
5351 }
5352 if (
5353 backgroundColor !== "rgba(0, 0, 0, 0)" &&
5354 backgroundColor !== "rgba(255, 255, 255, 0)" &&
5355 !has_background_img_url
5356 ) {
5357 if (!element.hasAttribute("data-darkify_secondary_bg_finder")) {
5358 element.dataset.darkify_secondary_bg_finder = backgroundColor;
5359 }
5360 if (darkify_secondary_bg_color !== "") {
5361 var isSecondaryBgColorDifferent =
5362 darkify_secondary_bg_color !==
5363 element.dataset.darkify_secondary_bg_finder;
5364 if (isSecondaryBgColorDifferent) {
5365 element.classList.add("darkify_style_secondary_bg");
5366 }
5367 delete element.dataset.darkify_secondary_bg_finder;
5368 }
5369 }
5370 if (
5371 backgroundColor !== "rgba(0, 0, 0, 0)" &&
5372 color !== "rgba(0, 0, 0, 0)" &&
5373 borderColor !== "rgba(0, 0, 0, 0)" &&
5374 backgroundColor !== "rgba(255, 255, 255, 0)" &&
5375 color !== "rgba(255, 255, 255, 0)" &&
5376 borderColor !== "rgba(255, 255, 255, 0)" &&
5377 has_background_img_url === false
5378 ) {
5379 element.classList.add("darkify_style_all");
5380 } else {
5381 if (
5382 backgroundColor !== "rgba(0, 0, 0, 0)" &&
5383 color !== "rgba(0, 0, 0, 0)" &&
5384 backgroundColor !== "rgba(255, 255, 255, 0)" &&
5385 color !== "rgba(255, 255, 255, 0)" &&
5386 has_background_img_url === false
5387 ) {
5388 element.classList.add("darkify_style_bg_txt");
5389 } else {
5390 if (
5391 backgroundColor !== "rgba(0, 0, 0, 0)" &&
5392 borderColor !== "rgba(0, 0, 0, 0)" &&
5393 backgroundColor !== "rgba(255, 255, 255, 0)" &&
5394 borderColor !== "rgba(255, 255, 255, 0)" &&
5395 has_background_img_url === false
5396 ) {
5397 element.classList.add("darkify_style_bg_border");
5398 } else {
5399 if (
5400 color !== "rgba(0, 0, 0, 0)" &&
5401 borderColor !== "rgba(0, 0, 0, 0)" &&
5402 color !== "rgba(255, 255, 255, 0)" &&
5403 borderColor !== "rgba(255, 255, 255, 0)"
5404 ) {
5405 element.classList.add("darkify_style_txt_border");
5406 } else {
5407 if (
5408 backgroundColor !== "rgba(0, 0, 0, 0)" &&
5409 backgroundColor !== "rgba(255, 255, 255, 0)" &&
5410 has_background_img_url === false
5411 ) {
5412 element.classList.add("darkify_style_bg");
5413 } else {
5414 if (
5415 color !== "rgba(0, 0, 0, 0)" &&
5416 color !== "rgba(255, 255, 255, 0)"
5417 ) {
5418 element.classList.add("darkify_style_txt");
5419 } else if (
5420 borderColor !== "rgba(0, 0, 0, 0)" &&
5421 borderColor !== "rgba(255, 255, 255, 0)"
5422 ) {
5423 element.classList.add("darkify_style_border");
5424 }
5425 }
5426 }
5427 }
5428 }
5429 }
5430 // A gradient-only element gets the secondary surface so a gradient section
5431 // reads as a surface rather than staying light — except when the gradient is
5432 // a scrim. A scrim element is transparent by design: it sits over a photo or
5433 // a patterned parent and its whole job is letting that show through. Painting
5434 // an opaque secondary background on it covers what it was drawn over, which
5435 // is the same erasure darkify_process_gradient() avoids one layer up — the
5436 // scrim there would survive, only for this class to paint over it anyway.
5437 if (
5438 backgroundImage !== "none" &&
5439 !has_background_img_url &&
5440 !darkify_is_scrim_gradient(backgroundImage) &&
5441 !element.classList.contains("darkify_style_all") &&
5442 !element.classList.contains("darkify_style_bg_txt") &&
5443 !element.classList.contains("darkify_style_bg_border") &&
5444 !element.classList.contains("darkify_style_bg")
5445 ) {
5446 element.classList.add("darkify_style_secondary_bg");
5447 }
5448
5449 if (nodeName === "a") {
5450 element.classList.add("darkify_style_link");
5451 }
5452
5453 if (
5454 nodeName === "input" ||
5455 nodeName === "select" ||
5456 nodeName === "textarea"
5457 ) {
5458 element.classList.add("darkify_style_form_element");
5459 }
5460
5461 const hasTargetClass = darkify_allowed_btn_class.some((cls) =>
5462 element.classList.contains(cls),
5463 );
5464
5465 // A `<button>` the design gave neither a background nor a border is a link
5466 // wearing a button tag — the pattern plugins use for "Remove", "Edit",
5467 // "Cancel" actions so they read as text but stay keyboard-operable. Painting
5468 // it with the button tokens invents a filled box the light-mode page never
5469 // had, and the hover token makes one appear under the cursor. Ghost buttons
5470 // are excluded from this: they carry a visible border, which is exactly what
5471 // marks them as a button rather than a link.
5472 //
5473 // An icon button is NOT link-like, even though it is just as backgroundless.
5474 // It has to keep the button treatment for its hover state: a themed hover is
5475 // the only thing standing between the user and the design's light-mode hover
5476 // colour, which on a dark page flares white under the cursor (Modern Cart's
5477 // quantity stepper hovers to `#f0f9ff`). Text is what makes a link a link, so
5478 // a button carrying a glyph instead stays a button.
5479 var carries_glyph = !!(
5480 element.querySelector && element.querySelector("svg, img, canvas")
5481 );
5482 var carries_text = !!(element.textContent || "").trim();
5483
5484 var paints_like_link =
5485 !carries_glyph &&
5486 carries_text &&
5487 (backgroundColor === "rgba(0, 0, 0, 0)" ||
5488 backgroundColor === "rgba(255, 255, 255, 0)") &&
5489 (borderColor === "rgba(0, 0, 0, 0)" ||
5490 borderColor === "rgba(255, 255, 255, 0)" ||
5491 computedStyle.borderTopWidth === "0px");
5492
5493 if (
5494 (nodeName === "button" || hasTargetClass || element.type === "submit") &&
5495 !paints_like_link
5496 ) {
5497 element.classList.add("darkify_style_button");
5498 element.classList.remove("darkify_style_secondary_bg");
5499 element.classList.remove("darkify_style_all");
5500 element.classList.remove("darkify_style_link");
5501 } else if (paints_like_link && nodeName === "button") {
5502 element.classList.add("darkify_style_link");
5503 }
5504
5505 if (
5506 (darkify_enable_low_image_brightness === "1" ||
5507 darkify_enable_image_grayscale === "1") &&
5508 nodeName === "img"
5509 ) {
5510 darkify_img_brightness_and_grayscale(element);
5511 }
5512
5513 if (darkify_enable_invert_inline_svg === "1" && nodeName === "svg") {
5514 darkify_invert_inline_svg(element);
5515 }
5516
5517 if (
5518 darkify_enable_low_video_brightness === "1" ||
5519 darkify_enable_video_grayscale === "1"
5520 ) {
5521 if (nodeName === "video") {
5522 darkify_video_brightness_and_grayscale(element);
5523 }
5524
5525 if (nodeName === "iframe") {
5526 const srcAttribute = element.getAttribute("src");
5527 if (srcAttribute !== null) {
5528 if (
5529 srcAttribute.includes("youtube") ||
5530 srcAttribute.includes("vimeo") ||
5531 srcAttribute.includes("dailymotion")
5532 ) {
5533 darkify_video_brightness_and_grayscale(element);
5534 }
5535 }
5536 }
5537 }
5538
5539 darkify_process_pseudo_surfaces(element, computedStyle);
5540 darkify_process_deterministic_fixes(element);
5541
5542 // Translucency last, run once per pass: a semi-transparent background left
5543 // as-is would let the dark page show through wherever the design intended
5544 // page-behind-panel; painting it opaque-dark keeps the panel a panel.
5545 var darkify_bg_alpha = darkify_parse_color(backgroundColor);
5546 if (
5547 darkify_bg_alpha &&
5548 darkify_bg_alpha.a > 0 &&
5549 darkify_bg_alpha.a < 1 &&
5550 !darkify_color_may_be_animating(computedStyle)
5551 ) {
5552 element.dataset.darkify_alpha_bg = backgroundColor;
5553 darkify_fix_background_color_alpha(element);
5554 }
5555
5556
5557 // if (old_transition !== "") {
5558 // setTimeout(function () {
5559 // element.style.setProperty("transition", old_transition);
5560 // }, 0);
5561 // }
5562
5563 element.classList.add("darkify_processed");
5564
5565 // The settled class string this pass produced. The delegated class watcher
5566 // compares against it to tell an external change apart from the engine's own
5567 // output, so it has to be written after the last `classList` call above.
5568 //
5569 // This replaces a per-element `setTimeout` that registered the class observer
5570 // on each element as it was processed — 3,348 timers and as many observer
5571 // registrations on the measured page, for a job one subtree observer now does
5572 // with a single registration made once at startup.
5573 element.dataset.darkify_preserved_classes = element.classList.toString();
5574 }
5575
5576 /**
5577 * Drop the pre-paint baseline set by the <head> snippet.
5578 *
5579 * That baseline is a blunt dark wash that exists only to cover the gap between
5580 * first paint and the first DOM walk. Once the walk has assigned real colours
5581 * it is not just redundant but wrong — it would keep flattening the things the
5582 * engine deliberately leaves alone, such as a builder's background overlay — so
5583 * it comes off as soon as the first pass is through.
5584 *
5585 * The snippet carries its own timeout that removes it regardless, so a failure
5586 * in here cannot leave the wash stuck on the page.
5587 */
5588 function darkify_finish_painting() {
5589 // Not while the parser is still producing the page. A walk that finishes
5590 // mid-parse has only covered what existed when it started, so dropping the
5591 // baseline here would expose every section the parser has not reached yet —
5592 // the page goes dark at the top and light further down, which is exactly the
5593 // split-page state this is meant to prevent. Once parsing is done, the walk
5594 // that follows has seen the whole document.
5595 if (document.readyState === "loading") {
5596 return;
5597 }
5598
5599 document
5600 .getElementsByTagName("html")[0]
5601 .classList.remove("darkify_prepaint");
5602 }
5603
5604 // Guarantees the walk that clears the baseline: the passes during parsing all
5605 // bail out of darkify_finish_painting(), so without a pass after parsing the
5606 // baseline would linger until the <head> snippet's timeout swept it away.
5607 //
5608 // Strictly gated on the baseline being present, i.e. on this page having loaded
5609 // in dark mode. A page that loads light must not be walked here: the engine
5610 // only wires up its observers on first use, and darkify_switch_trigger() does
5611 // that wiring precisely because no walk has happened yet. Walking early would
5612 // satisfy that check without wiring anything, and the switch would then flip
5613 // the class with nothing listening for it.
5614 if (document.readyState === "loading") {
5615 document.addEventListener("DOMContentLoaded", function () {
5616 if (
5617 document.documentElement.classList.contains("darkify_prepaint")
5618 ) {
5619 darkify_schedule_walk();
5620 }
5621 });
5622 }
5623
5624 function darkify_init_processes() {
5625 has_process_run_at_least_once = true;
5626 darkify_full_walk_needed = false;
5627 darkify_debug_count("full_walks");
5628
5629 darkify_debug_time("walk_ms", function () {
5630 darkify_suspend_class_watch(function () {
5631 document
5632 .querySelectorAll(DARKIFY_WALK_SELECTOR)
5633 .forEach(function (element) {
5634 darkify_process_element(element);
5635 });
5636 }, true);
5637 });
5638
5639 darkify_finish_painting();
5640 }
5641
5642 /**
5643 * Mirror the engine's dark state onto `dark` on <html> — admin panel only.
5644 *
5645 * A React-based admin screen doesn't take its colours from the cascade, so the
5646 * class-stamping the engine does to the rest of wp-admin can't reach it: it
5647 * reads design tokens that only switch when `dark` is on an ancestor
5648 * (Tailwind's class strategy, shadcn/ui, and Darkify's own React admin all key
5649 * on exactly that class). The <head> snippet in header_script.php sets it for
5650 * the first paint; this keeps it in step afterwards, so the admin-bar switch
5651 * re-themes those screens live instead of only after a reload.
5652 *
5653 * Deliberately generic — it mirrors our own state onto a shared convention and
5654 * names no plugin, so any admin app following that convention inherits the
5655 * theme. Screens with no such stylesheet loaded simply have an inert class.
5656 */
5657 function darkify_sync_react_dark_class() {
5658 if (
5659 typeof darkify_is_this_admin_panel === "undefined" ||
5660 darkify_is_this_admin_panel !== "1"
5661 ) {
5662 return;
5663 }
5664
5665 var html = document.documentElement;
5666 var should_be_dark = html.classList.contains("darkify_dark_mode_enabled");
5667
5668 // The no-op guard matters: this runs from an observer watching the same
5669 // element's class list, so toggling unconditionally would re-trigger it in a
5670 // loop. Bailing when nothing changes breaks the cycle.
5671 if (html.classList.contains("dark") === should_be_dark) {
5672 return;
5673 }
5674
5675 html.classList.toggle("dark", should_be_dark);
5676 }
5677
5678 function darkify_init_observer() {
5679 darkify_observer.observe(document, {
5680 attributes: false,
5681 childList: true,
5682 characterData: false,
5683 subtree: true,
5684 });
5685
5686 // Seeded before observing so the first genuine flip is detected as a change
5687 // rather than the observer mistaking the current state for one.
5688 darkify_last_swept_state = document.documentElement.classList.contains(
5689 "darkify_dark_mode_enabled",
5690 );
5691 dark_mode_status_changed.observe(document.getElementsByTagName("html")[0], {
5692 attributes: true,
5693 attributeFilter: ["class"],
5694 });
5695
5696 // One delegated registration for class changes across the whole document,
5697 // replacing the per-element registration that used to happen inside
5698 // darkify_process_element(). Subtree coverage means nodes added later are
5699 // watched from the moment they are inserted, with no re-registration pass.
5700 elements_class_changed.observe(document.documentElement, {
5701 attributes: true,
5702 attributeFilter: ["class"],
5703 subtree: true,
5704 });
5705
5706 // Keep `dark` in step with every route that flips `darkify_dark_mode_enabled`
5707 // (the admin-bar switch, the keyboard shortcut, OS/time-based changes, the
5708 // theme picker) without having to patch each one.
5709 darkify_sync_react_dark_class();
5710 new MutationObserver(darkify_sync_react_dark_class).observe(
5711 document.documentElement,
5712 { attributes: true, attributeFilter: ["class"] },
5713 );
5714
5715 if (document.readyState !== "loading") {
5716 if (!has_process_run_at_least_once) {
5717 darkify_init_processes();
5718 }
5719 darkify_implement_secondary_bg();
5720 darkify_apply_pseudo_bg_styles();
5721 darkify_recheck_on_css_loaded_later();
5722 darkify_restore_selected_theme();
5723 } else {
5724 document.addEventListener("DOMContentLoaded", function () {
5725 if (!has_process_run_at_least_once) {
5726 darkify_init_processes();
5727 }
5728 darkify_implement_secondary_bg();
5729 darkify_apply_pseudo_bg_styles();
5730 darkify_recheck_on_css_loaded_later();
5731 darkify_restore_selected_theme();
5732 });
5733 }
5734 }
5735
5736 if (!_dkf_iframe_disabled && darkify_check_preloading()) {
5737 document
5738 .getElementsByTagName("html")[0]
5739 .classList.add("darkify_dark_mode_enabled");
5740 darkify_init_observer();
5741 darkify_process_iframes(); // �
5742 darkify proceed iframe
5743 }
5744
5745 if (document.readyState !== "loading") {
5746 darkify_restore_selected_theme();
5747 } else {
5748 document.addEventListener("DOMContentLoaded", darkify_restore_selected_theme);
5749 }
5750