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 / admin_darkreader.js

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

843 lines 30.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Darkify — admin dark mode, Dark Reader engine.
3 *
4 * This replaces the per-element classifier (client_main.js) for wp-admin only.
5 * The frontend is untouched and still runs that engine.
6 *
7 * Why the swap: the classifier walks every element, reads getComputedStyle on
8 * each (plus ::before/::after), and re-runs on DOM mutations. wp-admin — the
9 * block editor above all — mutates continuously, and React rewrites classes on
10 * nodes the engine has stamped, so the walk never settles. Past a few hundred
11 * elements the editor stops responding.
12 *
13 * Dark Reader works at the stylesheet level instead: it parses the sheets once,
14 * emits an inverted shadow stylesheet, and watches for *new stylesheets* rather
15 * than element churn. Typing in the editor costs it nothing, so the failure
16 * mode is structurally absent rather than tuned away.
17 *
18 * What this file owns:
19 * - the on/off state for the admin (localStorage + the plugin's option)
20 * - the palette -> Dark Reader theme mapping
21 * - the same-origin editor iframes (block editor canvas, classic TinyMCE),
22 * which Dark Reader's page-level API does not reach on its own
23 *
24 * Integration contract: `darkify_switch_trigger()` and `darkify_theme_select()`
25 * must stay on `window` under exactly those names. The admin-bar node is
26 * registered with an inline `onclick="darkify_switch_trigger()"` (see
27 * Admin::darkify_admin_bar_switch), and the React settings screen calls
28 * `darkify_theme_select`. Renaming either breaks the toggle silently.
29 */
30 (function () {
31 "use strict";
32
33 if (typeof DarkReader === "undefined") {
34 return;
35 }
36
37 var HTML = document.documentElement;
38 var DARK_CLASS = "darkify_dark_mode_enabled";
39 var STATE_KEY = "darkify_admin_panel_last_state";
40 /*
41 * The editor's palette keeps the existing key, so the toolbar dropdown a user
42 * has already set carries over. The rest of wp-admin gets its own, because the
43 * two are independent choices.
44 */
45 var THEME_KEY = "darkify_selected_theme";
46 var ADMIN_THEME_KEY = "darkify_admin_palette";
47
48 /** Where to fetch the library from inside an iframe realm. Set by PHP. */
49 var LIB_SRC =
50 typeof window.darkifyDarkReaderSrc === "string"
51 ? window.darkifyDarkReaderSrc
52 : "";
53
54 /* ---------------------------------------------------------------------- */
55 /* Palettes */
56 /* ---------------------------------------------------------------------- */
57
58 /*
59 * Copied from client_main.js's darkify_apply_palette() so the admin shows the
60 * same named presets the frontend does. Only the fields Dark Reader can act
61 * on are carried across: it derives every other colour itself, which is the
62 * whole reason it handles unknown markup the classifier could not.
63 *
64 * The button/placeholder entries the frontend palette also carries have no
65 * Dark Reader equivalent and are deliberately dropped rather than faked.
66 */
67 var PALETTES = {
68 set1: { bg: "#0F0F0F", secondary_bg: "#171717", text_color: "#BEBEBE", link_color: "#E7E7E7", link_hover_color: "#BEBEBE", border_color: "#4A4A4A", input_bg: "#2D2D2D", input_text_color: "#BEBEBE", btn_bg: "#4A4A4A", btn_text_color: "#BEBEBE" },
69 set3: { bg: "#211e3c", secondary_bg: "#302C57", text_color: "#B1BBD8", link_color: "#8071fb", link_hover_color: "#B1BBD8", border_color: "#4E478D", input_bg: "#2A264D", input_text_color: "#B1BBD8", btn_bg: "#4E478D", btn_text_color: "#B1BBD8" },
70 set6: { bg: "#082032", secondary_bg: "#061825", text_color: "#B5D9F3", link_color: "#61bbff", link_hover_color: "#B5D9F3", border_color: "#144E78", input_bg: "#0E3755", input_text_color: "#B5D9F3", btn_bg: "#144E78", btn_text_color: "#B5D9F3" },
71 set9: { bg: "#04261d", secondary_bg: "#021e16", text_color: "#C1D2BB", link_color: "#00d29a", link_hover_color: "#C1D2BB", border_color: "#095541", input_bg: "#073d2f", input_text_color: "#C1D2BB", btn_bg: "#095541", btn_text_color: "#C1D2BB" },
72 set10: { bg: "#171004", secondary_bg: "#211706", text_color: "#E0D2BD", link_color: "#e09525", link_hover_color: "#E0D2BD", border_color: "#5D4010", input_bg: "#372911", input_text_color: "#E0D2BD", btn_bg: "#5D4010", btn_text_color: "#E0D2BD" },
73 };
74
75
76 /**
77 * Which palette applies here, or "auto" for Dark Reader's own derivation.
78 *
79 * "auto" is the default, and it is the better default. Forcing
80 * darkSchemeBackgroundColor/darkSchemeTextColor overrides the per-colour
81 * inversion that is the entire reason Dark Reader handles markup this plugin
82 * has never seen. Pinning two colours flattens that into "everything is this
83 * grey", and any theme whose surfaces carry meaning through colour loses it.
84 * A palette is now something a user opts into, not something they get.
85 *
86 * The block editor keeps its own choice, independent of the rest of wp-admin:
87 * the editor is where a writer is looking at their content and may well want a
88 * different surface than the one they want on the plugins list.
89 */
90 function isEditorContext() {
91 return !!(
92 document.body &&
93 (document.body.classList.contains("block-editor-page") ||
94 document.querySelector('iframe[name="editor-canvas"]'))
95 );
96 }
97
98 function paletteKey() {
99 return isEditorContext() ? THEME_KEY : ADMIN_THEME_KEY;
100 }
101
102 /**
103 * The site-wide default for this context, from the plugin's settings.
104 *
105 * Absent or unrecognised means "auto", which keeps installs that predate these
106 * two fields on Dark Reader's derivation rather than silently pinning them to
107 * a preset they never chose.
108 */
109 function paletteDefault() {
110 var value = isEditorContext()
111 ? window.darkify_editor_palette_default
112 : window.darkify_admin_palette_default;
113 return PALETTES[value] ? value : "auto";
114 }
115
116 /**
117 * The palette in force: a per-user choice if one exists, otherwise the site
118 * default. The editor's toolbar dropdown writes the per-user value, and it
119 * winning over the setting is the relationship that control has always had.
120 */
121 /*
122 * Unsaved values from the settings screen, so the two palette pickers show
123 * their effect while you are choosing rather than only after a save and a
124 * reload. Outranks both the per-user choice and the saved default for as long
125 * as the screen holds them; cleared when it stops previewing.
126 */
127 var previewOverrides = null;
128
129 function isPaletteValue(value) {
130 return value === "auto" || !!PALETTES[value];
131 }
132
133 function currentPaletteName() {
134 var context = isEditorContext() ? "editor" : "admin";
135
136 if (previewOverrides && isPaletteValue(previewOverrides[context])) {
137 return previewOverrides[context];
138 }
139
140 var stored;
141 try {
142 stored = localStorage.getItem(paletteKey());
143 } catch (e) {
144 stored = null;
145 }
146
147 if (isPaletteValue(stored)) {
148 return stored;
149 }
150
151 return paletteDefault();
152 }
153
154 /** The palette record, or null when running on Dark Reader's own colours. */
155 function currentPalette() {
156 var name = currentPaletteName();
157 return name === "auto" ? null : PALETTES[name];
158 }
159
160 /* ---------------------------------------------------------------------- */
161 /* Theme + fixes */
162 /* ---------------------------------------------------------------------- */
163
164 function buildTheme() {
165 var theme = {
166 mode: 1,
167 brightness: 100,
168 contrast: 100,
169 grayscale: 0,
170 sepia: 0,
171 selectionColor: "auto",
172 // wp-admin's form controls are styled by WordPress, not by the UA. Letting
173 // Dark Reader restyle system controls on top of that double-darkens them.
174 styleSystemControls: false,
175 };
176
177 var p = currentPalette();
178 if (p) {
179 theme.darkSchemeBackgroundColor = p.bg;
180 theme.darkSchemeTextColor = p.text_color;
181 theme.scrollbarColor = p.secondary_bg;
182 }
183
184 return theme;
185 }
186
187 function buildFixes() {
188 var p = currentPalette();
189
190 /*
191 * The two-tone page.
192 *
193 * wp-admin paints `body { background: #f0f0f1 }` and leaves <html> unpainted.
194 * Dark Reader derives body's dark colour *from* #f0f0f1, but paints <html>
195 * with darkSchemeBackgroundColor — a different value. Whenever the content is
196 * shorter than the viewport, <html> shows below <body> and the join is
197 * visible as a horizontal seam, usually right around 100vh. (The same seam
198 * appears in the Dark Reader extension and in other plugins built on it; it
199 * is inherent to theming those two elements independently, not to this
200 * integration.)
201 *
202 * `${...}` hands the colour to Dark Reader's own processing, so <html> is
203 * asked for the same derivation <body> gets. This alone does NOT settle it —
204 * Dark Reader's own root rule is `!important` as well and outranks this one —
205 * so syncRootBackground() pins the real value inline after the fact. This
206 * rule stays as the pre-paint approximation, narrowing the seam before that
207 * measurement can happen; do not remove it, and do not assume it is
208 * sufficient on its own.
209 *
210 * Matching the colours is the fix either way; stretching #wpwrap to full
211 * height only moves the seam somewhere less obvious.
212 */
213 var css =
214 "html { background-color: ${#f0f0f1} !important; }\n" +
215 // The login and about screens paint a different base colour.
216 "body.login, body.about-php { background-color: ${#f0f0f1} !important; }\n";
217
218 /*
219 * Only a chosen palette asserts a link colour. On "auto", Dark Reader's
220 * derived link colour is the correct one — it is computed from the link's
221 * own original colour, so a theme that colours its links deliberately keeps
222 * that distinction instead of having it overwritten.
223 */
224 if (p) {
225 css +=
226 "a, a:visited { color: " + p.link_color + " !important; }\n" +
227 "a:hover, a:focus { color: " + p.link_hover_color + " !important; }\n";
228 }
229
230 return {
231 invert: [],
232
233 css: css,
234
235 /*
236 * Inline styles that carry meaning rather than design. A colour picker's
237 * swatch *is* the value it represents — inverting it makes the control
238 * lie about what it will apply. Same for the block editor's palette
239 * circles and the theme/pattern previews, which are showing the user the
240 * light-mode design on purpose.
241 */
242 ignoreInlineStyle: [
243 ".darkify_switch",
244 ".darkify_ignore",
245 ".wp-picker-container",
246 ".wp-color-result",
247 ".color-option",
248 ".components-color-picker",
249 ".components-circular-option-picker__option",
250 ".components-palette-edit__colors",
251 ".block-editor-color-gradient-control",
252 ".block-editor-block-preview__container",
253 ".block-editor-block-preview__content",
254 ".block-editor-patterns__list",
255 ".editor-styles-wrapper",
256 ],
257
258 /*
259 * Image analysis fetches and samples every image to decide whether to
260 * invert it. In wp-admin that means the whole media library, and a
261 * screenshot or a logo inverted "helpfully" is simply wrong. Off wholesale.
262 */
263 ignoreImageAnalysis: ["*"],
264
265 disableStyleSheetsProxy: false,
266 ignoreCSSUrl: [],
267 };
268 }
269
270 /* ---------------------------------------------------------------------- */
271 /* State */
272 /* ---------------------------------------------------------------------- */
273
274 /**
275 * Whether Admin Panel Dark Mode is switched on in the plugin's settings.
276 *
277 * Darkify's own settings screens load this engine even while the option is
278 * off, so the admin-bar icon can appear the moment it is switched on without
279 * a reload. A stale remembered state must not darken those screens. An absent
280 * flag counts as enabled, which keeps installs that have not re-saved since
281 * the flag was introduced behaving as they did.
282 */
283 /**
284 * Darkify's own React settings screens ship a complete dark theme of their own
285 * (`.dark { --background: oklch(...) }` in darkify-react/src/index.css),
286 * mirrored onto <html> by adminDarkMode.js and set pre-paint by PHP. It styles
287 * the surrounding wp-admin chrome too (`.dark #wpcontent`).
288 *
289 * Running Dark Reader over that darkens an already-dark app twice, and Dark
290 * Reader 4.9 does not parse `oklch()`, so the unresolved custom properties
291 * settle on neighbouring token values — which is why card borders and toggle
292 * backgrounds came out red (`--destructive`). The app themes itself; the
293 * engine's only job on these screens is to keep the toggle working.
294 */
295 function isSelfThemedScreen() {
296 return window.darkifyAdminSelfThemed === true;
297 }
298
299 function optionEnabled() {
300 return (
301 typeof window.darkify_admin_panel_dark_enabled === "undefined" ||
302 window.darkify_admin_panel_dark_enabled === "1"
303 );
304 }
305
306 function readState() {
307 try {
308 return localStorage.getItem(STATE_KEY) === "1";
309 } catch (e) {
310 return false;
311 }
312 }
313
314 function writeState(on) {
315 try {
316 localStorage.setItem(STATE_KEY, on ? "1" : "0");
317 } catch (e) {
318 // Private mode / blocked storage: the toggle still works for this page
319 // load, it just will not be remembered.
320 }
321 }
322
323 function isDark() {
324 return HTML.classList.contains(DARK_CLASS);
325 }
326
327 /* ---------------------------------------------------------------------- */
328 /* Iframes */
329 /* ---------------------------------------------------------------------- */
330
331 /*
332 * The block editor canvas (WP 6.3+) and the classic editor's TinyMCE body are
333 * separate documents. Dark Reader's page-level API binds the realm it was
334 * loaded into, so each same-origin frame needs its own copy loaded inside it
335 * and enabled there. Cross-origin frames are unreachable and left alone.
336 */
337 var IFRAME_SELECTOR =
338 'iframe[name="editor-canvas"], iframe#content_ifr, .mce-container-body iframe';
339
340 /** The classic editor's own toggle state, written by admin-classic-editor.js. */
341 var CLASSIC_MODE_KEY = "darkify_classic_editor_mode";
342
343 function isClassicFrame(iframe) {
344 return (
345 iframe.id === "content_ifr" ||
346 !!(iframe.closest && iframe.closest(".mce-container-body"))
347 );
348 }
349
350 /**
351 * Whether the classic editor's content area should be dark.
352 *
353 * The TinyMCE toolbar carries its own moon/sun button with its own remembered
354 * state, so unlike the block editor canvas this frame is not simply "whatever
355 * the admin is". Honouring only the page state made that button do nothing:
356 * it removed the legacy stylesheet it manages while Dark Reader carried on
357 * painting the frame dark underneath.
358 *
359 * With no remembered choice the frame follows the admin, which is what someone
360 * who has never touched the button expects.
361 */
362 function classicWantsDark(pageDark) {
363 var stored;
364 try {
365 stored = localStorage.getItem(CLASSIC_MODE_KEY);
366 } catch (e) {
367 stored = null;
368 }
369
370 if (stored === "1") return true;
371 if (stored === "0") return false;
372 return pageDark;
373 }
374
375 /**
376 * Put the Dark Reader bundle into a same-origin frame without enabling it.
377 *
378 * Idempotent: the script element's id is the guard, so repeated calls (every
379 * toggle, every canvas re-scan) load it once. Enabling is deliberately not
380 * done here — this only makes `win.DarkReader` exist so a later enable() is
381 * the sole cost.
382 */
383 function ensureLibraryInFrame(doc) {
384 if (!LIB_SRC || !doc || doc.getElementById("darkify-darkreader-lib")) {
385 return;
386 }
387 var win = doc.defaultView;
388 if (!win || win.DarkReader) {
389 return;
390 }
391
392 var script = doc.createElement("script");
393 script.id = "darkify-darkreader-lib";
394 script.src = LIB_SRC;
395 (doc.head || doc.documentElement).appendChild(script);
396 }
397
398 function applyToIframe(iframe, enabled) {
399 if (isClassicFrame(iframe)) {
400 /*
401 * Replaces the page state rather than narrowing it. The classic editor's
402 * moon button darkens the content frame on its own terms — a light admin
403 * with a dark writing area is a combination people deliberately choose,
404 * and the legacy stylesheet this replaced supported it. Writing this as
405 * `enabled && classicWantsDark(...)` made page-dark a precondition, so the
406 * button did nothing whenever the admin was light.
407 */
408 enabled = classicWantsDark(enabled);
409 }
410
411 var doc;
412 var win;
413 try {
414 doc = iframe.contentDocument;
415 win = iframe.contentWindow;
416 } catch (e) {
417 return; // cross-origin
418 }
419 if (!doc || !doc.documentElement || !win) {
420 return;
421 }
422
423 if (!enabled) {
424 try {
425 if (win.DarkReader && win.DarkReader.isEnabled()) {
426 win.DarkReader.disable();
427 }
428 } catch (e) {
429 // Frame navigated out from under us.
430 }
431
432 /*
433 * Load the library into the frame anyway, while nothing is waiting on it.
434 *
435 * Turning dark on is inherently slower than turning it off — enable()
436 * analyses every stylesheet and generates an inverted one, disable() only
437 * tears down what already exists. That asymmetry belongs to Dark Reader
438 * and cannot be removed here. What can be removed is the rest of the first
439 * toggle's bill: without this, the first light->dark in the editor also
440 * pays to fetch 106 KB into the canvas frame and parse it, before the
441 * analysis has even started. Paying that during idle time after load means
442 * the click only costs the part that is genuinely unavoidable.
443 */
444 ensureLibraryInFrame(doc);
445 return;
446 }
447
448 if (win.DarkReader) {
449 try {
450 win.DarkReader.enable(buildTheme(), buildFixes());
451 } catch (e) {
452 // ignore
453 }
454 return;
455 }
456
457 if (!LIB_SRC || doc.getElementById("darkify-darkreader-lib")) {
458 return; // no URL to load, or a load is already in flight
459 }
460
461 var script = doc.createElement("script");
462 script.id = "darkify-darkreader-lib";
463 script.src = LIB_SRC;
464 script.onload = function () {
465 try {
466 win.DarkReader.setFetchMethod(win.fetch.bind(win));
467 win.DarkReader.enable(buildTheme(), buildFixes());
468 } catch (e) {
469 // ignore
470 }
471 };
472 (doc.head || doc.documentElement).appendChild(script);
473 }
474
475 function applyToAllIframes(enabled) {
476 var frames = document.querySelectorAll(IFRAME_SELECTOR);
477 for (var i = 0; i < frames.length; i++) {
478 var frame = frames[i];
479 // Re-apply after every navigation of the frame, bound once per element.
480 if (!frame.dataset.darkifyDrBound) {
481 frame.dataset.darkifyDrBound = "1";
482 frame.addEventListener("load", function () {
483 applyToIframe(this, isDark());
484 });
485 }
486 applyToIframe(frame, enabled);
487 }
488 }
489
490 /*
491 * The canvas iframe is mounted asynchronously, well after this script runs,
492 * and is replaced when the editor switches between visual and code view. One
493 * cheap querySelector per frame is enough to notice; the rAF gate keeps the
494 * editor's constant DOM churn from turning that into per-mutation work.
495 */
496 var scanScheduled = false;
497
498 function scheduleIframeScan() {
499 if (scanScheduled) {
500 return;
501 }
502 scanScheduled = true;
503 requestAnimationFrame(function () {
504 scanScheduled = false;
505 if (document.querySelector(IFRAME_SELECTOR)) {
506 applyToAllIframes(isDark());
507 }
508 });
509 }
510
511 var frameWatcher = null;
512
513 function watchForIframes() {
514 // Reachable from both boot paths and from every toggle; a second observer
515 // on the same body would double every scan for no benefit.
516 if (frameWatcher || !document.body) {
517 return;
518 }
519 frameWatcher = new MutationObserver(scheduleIframeScan);
520 frameWatcher.observe(document.body, {
521 childList: true,
522 subtree: true,
523 });
524 }
525
526 /* ---------------------------------------------------------------------- */
527 /* Apply */
528 /* ---------------------------------------------------------------------- */
529
530 /*
531 * Darkify's own screens are themed by their own design tokens, not by Dark
532 * Reader, so a chosen palette has to reach them a different way: by writing
533 * the palette's colours into those tokens directly.
534 *
535 * The map is deliberately partial. Four groups are left alone:
536 *
537 * --destructive a delete button that stops being red
538 * stops communicating danger.
539 * --chart-* categorical colours; they have to stay
540 * distinguishable from each other.
541 *
542 * --primary and --sidebar-primary ARE mapped, and the pairing that made them
543 * look risky is what makes them safe: they take `btn_bg` and `btn_text_color`,
544 * which is the palette's own button fill and its text — a pair its author
545 * already chose to be readable together. Taking both halves from that one pair
546 * is a different thing from overwriting half of shadcn's. Without them the
547 * switches, primary buttons and selected states kept a near-white default and
548 * were the only parts of the screen a chosen palette never reached.
549 */
550 var SELF_THEMED_TOKENS = {
551 "--background": "bg",
552 "--card": "secondary_bg",
553 "--popover": "secondary_bg",
554 "--sidebar": "secondary_bg",
555 "--secondary": "secondary_bg",
556 "--muted": "secondary_bg",
557 "--accent": "secondary_bg",
558 "--sidebar-accent": "secondary_bg",
559 "--foreground": "text_color",
560 "--card-foreground": "text_color",
561 "--popover-foreground": "text_color",
562 "--secondary-foreground": "text_color",
563 "--accent-foreground": "text_color",
564 "--sidebar-foreground": "text_color",
565 "--sidebar-accent-foreground": "text_color",
566 "--muted-foreground": "input_text_color",
567 "--border": "border_color",
568 "--input": "border_color",
569 "--sidebar-border": "border_color",
570 "--ring": "link_color",
571 "--primary": "btn_bg",
572 "--primary-foreground": "btn_text_color",
573 "--sidebar-primary": "btn_bg",
574 "--sidebar-primary-foreground": "btn_text_color",
575 };
576
577 /**
578 * Push the active palette into the self-themed app's tokens, or clear them.
579 *
580 * Cleared on "auto" and in light mode alike, which hands the app back to the
581 * tokens its own stylesheet defines rather than leaving a half-applied
582 * palette behind.
583 */
584 function applySelfThemedTokens() {
585 var p = isDark() ? currentPalette() : null;
586
587 for (var token in SELF_THEMED_TOKENS) {
588 if (!Object.prototype.hasOwnProperty.call(SELF_THEMED_TOKENS, token)) {
589 continue;
590 }
591 var value = p ? p[SELF_THEMED_TOKENS[token]] : null;
592
593 /*
594 * A missing field clears the token rather than writing it.
595 *
596 * setProperty() stringifies whatever it is handed, so a key this palette
597 * does not carry became the literal text "undefined" — a custom property
598 * that parses but can never resolve. Every `var()` reading it then failed,
599 * and a failed var() takes its whole declaration with it: the switch track
600 * did not fall back to a default colour, it lost its background entirely
601 * and rendered transparent. Clearing instead lets the stylesheet's own
602 * value stand, which is wrong-looking at worst rather than invisible.
603 */
604 if (value) {
605 HTML.style.setProperty(token, value);
606 } else {
607 HTML.style.removeProperty(token);
608 }
609 }
610 }
611
612 /*
613 * Erase the horizontal seam near the bottom of short admin pages.
614 *
615 * wp-admin paints `body { background: #f0f0f1 }` and leaves <html> unpainted.
616 * Dark Reader derives body's colour from #f0f0f1 but paints <html> with
617 * darkSchemeBackgroundColor — a different value. When the content is shorter
618 * than the viewport, <html> shows below <body> and the join is visible. The
619 * admin menu column ends at the same line, which is what makes the seam run
620 * the full width of the page.
621 *
622 * Declaring `html { background-color: ${#f0f0f1} }` in fixes.css does not win:
623 * Dark Reader's own generated rule for the root element is `!important` too,
624 * and it is the one that applies. So rather than predicting the colour, read
625 * back the one it actually produced for <body> and pin <html> to it inline —
626 * an inline `!important` outranks any stylesheet, including its own.
627 *
628 * Runs twice on purpose. Dark Reader processes stylesheets as it finds them,
629 * and a sheet that arrives late (an admin page loading its own CSS) can change
630 * what body resolves to after the first read.
631 */
632 function syncRootBackground() {
633 if (!document.body) {
634 return;
635 }
636
637 var color = window.getComputedStyle(document.body).backgroundColor;
638
639 // Transparent means body is not painting anything of its own, so there is no
640 // second colour to reconcile and nothing to correct.
641 if (!color || color === "transparent" || color === "rgba(0, 0, 0, 0)") {
642 HTML.style.removeProperty("background-color");
643 return;
644 }
645
646 HTML.style.setProperty("background-color", color, "important");
647 }
648
649 function scheduleRootBackgroundSync() {
650 if (typeof requestAnimationFrame === "function") {
651 requestAnimationFrame(syncRootBackground);
652 } else {
653 setTimeout(syncRootBackground, 0);
654 }
655 setTimeout(syncRootBackground, 300);
656 }
657
658 function apply() {
659 var enabled = isDark();
660
661 if (isSelfThemedScreen()) {
662 // The page's own CSS does the painting here; the palette reaches it
663 // through its tokens. Disable defensively in case a previous navigation
664 // on this document had Dark Reader on.
665 DarkReader.disable();
666 applySelfThemedTokens();
667 return;
668 }
669
670 if (enabled) {
671 DarkReader.enable(buildTheme(), buildFixes());
672 } else {
673 DarkReader.disable();
674 }
675
676 if (enabled) {
677 scheduleRootBackgroundSync();
678 } else {
679 HTML.style.removeProperty("background-color");
680 }
681
682 applyToAllIframes(enabled);
683 watchForIframes();
684 }
685
686 /**
687 * Keep the palette class on <html> in step with the frontend engine's
688 * convention, so CSS that keys off it (the switch, the admin bar icon) still
689 * matches. "auto" gets no class — there is no palette to name.
690 */
691 function applyPaletteClass() {
692 var classes = Array.prototype.slice.call(HTML.classList);
693 for (var i = 0; i < classes.length; i++) {
694 if (classes[i].indexOf("darkify-set") === 0) {
695 HTML.classList.remove(classes[i]);
696 }
697 }
698
699 var name = currentPaletteName();
700 if (name !== "auto") {
701 HTML.classList.add("darkify-" + name);
702 }
703 }
704
705 /* ---------------------------------------------------------------------- */
706 /* Public API (names are load-bearing — see the file header) */
707 /* ---------------------------------------------------------------------- */
708
709 window.darkify_switch_trigger = function () {
710 if (!optionEnabled()) {
711 return;
712 }
713 HTML.classList.toggle(DARK_CLASS);
714 writeState(isDark());
715 apply();
716 };
717
718 window.darkify_theme_select = function (theme) {
719 // "auto" is a valid choice, not a missing one: it hands the colours back to
720 // Dark Reader's derivation.
721 if (isPaletteValue(theme)) {
722 try {
723 localStorage.setItem(paletteKey(), theme);
724 } catch (e) {
725 // not remembered, still applied below
726 }
727 }
728
729 applyPaletteClass();
730 apply();
731 };
732
733 /** Exposed for support: what the engine thinks it is doing right now. */
734 window.darkifyAdminEngine = {
735 engine: "darkreader",
736 version: typeof DarkReader.getVersion === "function" ? DarkReader.getVersion() : null,
737 isDark: isDark,
738 palette: currentPaletteName,
739 paletteKey: paletteKey,
740 selfThemed: isSelfThemedScreen,
741 palettes: function () {
742 return ["auto"].concat(Object.keys(PALETTES));
743 },
744 theme: buildTheme,
745 fixes: buildFixes,
746 reapply: apply,
747
748 /**
749 * Preview unsaved palette choices.
750 *
751 * @param {{admin?: string, editor?: string}|null} overrides
752 * Palette ids to show, or null to drop back to saved values.
753 */
754 preview: function (overrides) {
755 previewOverrides = overrides || null;
756 applyPaletteClass();
757 apply();
758 },
759 };
760
761 /* ---------------------------------------------------------------------- */
762 /* Boot */
763 /* ---------------------------------------------------------------------- */
764
765 function init() {
766 // Cross-origin admin stylesheets (a CDN-hosted plugin sheet) are otherwise
767 // skipped, leaving patches of the admin un-darkened.
768 try {
769 DarkReader.setFetchMethod(window.fetch.bind(window));
770 } catch (e) {
771 // ignore
772 }
773
774 var on = optionEnabled() && readState();
775
776 if (on) {
777 HTML.classList.add(DARK_CLASS);
778 applyPaletteClass();
779 } else {
780 HTML.classList.remove(DARK_CLASS);
781 }
782
783 if (on && !isSelfThemedScreen()) {
784 DarkReader.enable(buildTheme(), buildFixes());
785 } else {
786 DarkReader.disable();
787 }
788
789 if (isSelfThemedScreen()) {
790 applySelfThemedTokens();
791 } else if (on) {
792 // init() runs in <head>, where there is no <body> to measure yet.
793 if (document.readyState === "loading") {
794 document.addEventListener("DOMContentLoaded", scheduleRootBackgroundSync);
795 } else {
796 scheduleRootBackgroundSync();
797 }
798 }
799 }
800
801 /*
802 * Two-stage boot, and the split is the whole point.
803 *
804 * The paint has to be claimed before the browser makes one: this script is
805 * printed in <head>, and Dark Reader is built to run there — enable() does not
806 * need a parsed body. Waiting for DOMContentLoaded to call it would let the
807 * light admin paint first and then swap, which is exactly the flash the
808 * pre-paint snippet in header_script.php exists to prevent.
809 *
810 * The iframe work genuinely does need a body to query and observe, so only
811 * that half waits.
812 */
813 init();
814
815 if (document.readyState === "loading") {
816 document.addEventListener("DOMContentLoaded", initFrames);
817 } else {
818 initFrames();
819 }
820
821 function initFrames() {
822 if (isSelfThemedScreen()) {
823 return;
824 }
825
826 /*
827 * init() ran in <head>, where there is no <body> to test — so
828 * isEditorContext() was necessarily false and the page was themed with the
829 * admin palette. On the block editor that is the wrong one, and it would
830 * have left the editor's chrome on the admin palette while only the canvas
831 * picked up the editor's. Now that the body exists the context is knowable,
832 * so re-theme the page before touching the frames.
833 */
834 if (isEditorContext()) {
835 apply();
836 return; // apply() reaches the frames itself
837 }
838
839 applyToAllIframes(isDark());
840 watchForIframes();
841 }
842 })();
843