PluginProbe
Darkify – Dark Mode & Night Mode for Website & Admin (Dark Theme Included) / 2.1.3
Darkify – Dark Mode & Night Mode for Website & Admin (Dark Theme Included) v2.1.3
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.3, at src/assets/js/admin_darkreader.js

880 lines 32.4 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 /*
226 * Specificity, not just !important.
227 *
228 * Dark Reader rewrites every stylesheet rule it darkens and marks the
229 * result `!important`, so a plain `a { ... !important }` no longer beats
230 * core's own link rules — it only ties with them, and the more specific
231 * one wins. `div.notice a, div.error a, div.updated a` (wp-admin
232 * common.css, specificity 0,1,2) is exactly that case: list-table and
233 * row-action links took the palette colour while every link inside an
234 * admin notice stayed WordPress blue.
235 *
236 * `html body a` plus the :not() chain lands at 0,5,3 — above core's
237 * class-based link rules, and still below anything anchored to an id, so
238 * #adminmenu and #wpadminbar keep Dark Reader's derived colours (a bright
239 * palette link colour on the menu would be far too loud).
240 *
241 * The exclusions are the buttons: they carry their own fill, and their
242 * label has to stay readable against it rather than become a link colour.
243 */
244 var notButton =
245 ":not(.button)" +
246 ":not(.button-primary)" +
247 ":not(.button-secondary)" +
248 ":not(.button-link)" +
249 ":not(.page-title-action)";
250
251 css +=
252 "html body a" + notButton + ", html body a" + notButton + ":visited" +
253 " { color: " + p.link_color + " !important; }\n" +
254 "html body a" + notButton + ":hover, html body a" + notButton + ":focus" +
255 " { color: " + p.link_hover_color + " !important; }\n";
256 }
257
258 return {
259 invert: [],
260
261 css: css,
262
263 /*
264 * Inline styles that carry meaning rather than design. A colour picker's
265 * swatch *is* the value it represents — inverting it makes the control
266 * lie about what it will apply. Same for the block editor's palette
267 * circles and the theme/pattern previews, which are showing the user the
268 * light-mode design on purpose.
269 */
270 ignoreInlineStyle: [
271 ".darkify_switch",
272 ".darkify_ignore",
273 ".wp-picker-container",
274 ".wp-color-result",
275 ".color-option",
276 ".components-color-picker",
277 ".components-circular-option-picker__option",
278 ".components-palette-edit__colors",
279 ".block-editor-color-gradient-control",
280 ".block-editor-block-preview__container",
281 ".block-editor-block-preview__content",
282 ".block-editor-patterns__list",
283 ".editor-styles-wrapper",
284 ],
285
286 /*
287 * Image analysis fetches and samples every image to decide whether to
288 * invert it. In wp-admin that means the whole media library, and a
289 * screenshot or a logo inverted "helpfully" is simply wrong. Off wholesale.
290 */
291 ignoreImageAnalysis: ["*"],
292
293 disableStyleSheetsProxy: false,
294 ignoreCSSUrl: [],
295 };
296 }
297
298 /* ---------------------------------------------------------------------- */
299 /* State */
300 /* ---------------------------------------------------------------------- */
301
302 /**
303 * Whether Admin Panel Dark Mode is switched on in the plugin's settings.
304 *
305 * Darkify's own settings screens load this engine even while the option is
306 * off, so the admin-bar icon can appear the moment it is switched on without
307 * a reload. A stale remembered state must not darken those screens. An absent
308 * flag counts as enabled, which keeps installs that have not re-saved since
309 * the flag was introduced behaving as they did.
310 */
311 /**
312 * Darkify's own React settings screens ship a complete dark theme of their own
313 * (`.dark { --background: oklch(...) }` in darkify-react/src/index.css),
314 * mirrored onto <html> by adminDarkMode.js and set pre-paint by PHP. It styles
315 * the surrounding wp-admin chrome too (`.dark #wpcontent`).
316 *
317 * Running Dark Reader over that darkens an already-dark app twice, and Dark
318 * Reader 4.9 does not parse `oklch()`, so the unresolved custom properties
319 * settle on neighbouring token values — which is why card borders and toggle
320 * backgrounds came out red (`--destructive`). The app themes itself; the
321 * engine's only job on these screens is to keep the toggle working.
322 */
323 function isSelfThemedScreen() {
324 return window.darkifyAdminSelfThemed === true;
325 }
326
327 function optionEnabled() {
328 return (
329 typeof window.darkify_admin_panel_dark_enabled === "undefined" ||
330 window.darkify_admin_panel_dark_enabled === "1"
331 );
332 }
333
334 function readState() {
335 try {
336 return localStorage.getItem(STATE_KEY) === "1";
337 } catch (e) {
338 return false;
339 }
340 }
341
342 function writeState(on) {
343 try {
344 localStorage.setItem(STATE_KEY, on ? "1" : "0");
345 } catch (e) {
346 // Private mode / blocked storage: the toggle still works for this page
347 // load, it just will not be remembered.
348 }
349 }
350
351 function isDark() {
352 return HTML.classList.contains(DARK_CLASS);
353 }
354
355 /* ---------------------------------------------------------------------- */
356 /* Iframes */
357 /* ---------------------------------------------------------------------- */
358
359 /*
360 * The block editor canvas (WP 6.3+) and the classic editor's TinyMCE body are
361 * separate documents. Dark Reader's page-level API binds the realm it was
362 * loaded into, so each same-origin frame needs its own copy loaded inside it
363 * and enabled there. Cross-origin frames are unreachable and left alone.
364 */
365 var IFRAME_SELECTOR =
366 'iframe[name="editor-canvas"], iframe#content_ifr, .mce-container-body iframe';
367
368 /** The classic editor's own toggle state, written by admin-classic-editor.js. */
369 var CLASSIC_MODE_KEY = "darkify_classic_editor_mode";
370
371 function isClassicFrame(iframe) {
372 return (
373 iframe.id === "content_ifr" ||
374 !!(iframe.closest && iframe.closest(".mce-container-body"))
375 );
376 }
377
378 /**
379 * Whether the classic editor's content area should be dark.
380 *
381 * The TinyMCE toolbar carries its own moon/sun button with its own remembered
382 * state, so unlike the block editor canvas this frame is not simply "whatever
383 * the admin is". Honouring only the page state made that button do nothing:
384 * it removed the legacy stylesheet it manages while Dark Reader carried on
385 * painting the frame dark underneath.
386 *
387 * With no remembered choice the frame follows the admin, which is what someone
388 * who has never touched the button expects.
389 */
390 function classicWantsDark(pageDark) {
391 var stored;
392 try {
393 stored = localStorage.getItem(CLASSIC_MODE_KEY);
394 } catch (e) {
395 stored = null;
396 }
397
398 if (stored === "1") return true;
399 if (stored === "0") return false;
400 return pageDark;
401 }
402
403 /**
404 * Put the Dark Reader bundle into a same-origin frame without enabling it.
405 *
406 * Idempotent: the script element's id is the guard, so repeated calls (every
407 * toggle, every canvas re-scan) load it once. Enabling is deliberately not
408 * done here — this only makes `win.DarkReader` exist so a later enable() is
409 * the sole cost.
410 */
411 function ensureLibraryInFrame(doc) {
412 if (!LIB_SRC || !doc || doc.getElementById("darkify-darkreader-lib")) {
413 return;
414 }
415 var win = doc.defaultView;
416 if (!win || win.DarkReader) {
417 return;
418 }
419
420 var script = doc.createElement("script");
421 script.id = "darkify-darkreader-lib";
422 script.src = LIB_SRC;
423 (doc.head || doc.documentElement).appendChild(script);
424 }
425
426 function applyToIframe(iframe, enabled) {
427 if (isClassicFrame(iframe)) {
428 /*
429 * Replaces the page state rather than narrowing it. The classic editor's
430 * moon button darkens the content frame on its own terms — a light admin
431 * with a dark writing area is a combination people deliberately choose,
432 * and the legacy stylesheet this replaced supported it. Writing this as
433 * `enabled && classicWantsDark(...)` made page-dark a precondition, so the
434 * button did nothing whenever the admin was light.
435 */
436 enabled = classicWantsDark(enabled);
437 }
438
439 var doc;
440 var win;
441 try {
442 doc = iframe.contentDocument;
443 win = iframe.contentWindow;
444 } catch (e) {
445 return; // cross-origin
446 }
447 if (!doc || !doc.documentElement || !win) {
448 return;
449 }
450
451 if (!enabled) {
452 try {
453 if (win.DarkReader && win.DarkReader.isEnabled()) {
454 win.DarkReader.disable();
455 }
456 } catch (e) {
457 // Frame navigated out from under us.
458 }
459
460 /*
461 * Load the library into the frame anyway, while nothing is waiting on it.
462 *
463 * Turning dark on is inherently slower than turning it off — enable()
464 * analyses every stylesheet and generates an inverted one, disable() only
465 * tears down what already exists. That asymmetry belongs to Dark Reader
466 * and cannot be removed here. What can be removed is the rest of the first
467 * toggle's bill: without this, the first light->dark in the editor also
468 * pays to fetch 106 KB into the canvas frame and parse it, before the
469 * analysis has even started. Paying that during idle time after load means
470 * the click only costs the part that is genuinely unavoidable.
471 */
472 ensureLibraryInFrame(doc);
473 return;
474 }
475
476 if (win.DarkReader) {
477 try {
478 win.DarkReader.enable(buildTheme(), buildFixes());
479 } catch (e) {
480 // ignore
481 }
482 return;
483 }
484
485 if (!LIB_SRC || doc.getElementById("darkify-darkreader-lib")) {
486 return; // no URL to load, or a load is already in flight
487 }
488
489 var script = doc.createElement("script");
490 script.id = "darkify-darkreader-lib";
491 script.src = LIB_SRC;
492 script.onload = function () {
493 try {
494 win.DarkReader.setFetchMethod(win.fetch.bind(win));
495 win.DarkReader.enable(buildTheme(), buildFixes());
496 } catch (e) {
497 // ignore
498 }
499 };
500 (doc.head || doc.documentElement).appendChild(script);
501 }
502
503 function applyToAllIframes(enabled) {
504 var frames = document.querySelectorAll(IFRAME_SELECTOR);
505 for (var i = 0; i < frames.length; i++) {
506 var frame = frames[i];
507 // Re-apply after every navigation of the frame, bound once per element.
508 if (!frame.dataset.darkifyDrBound) {
509 frame.dataset.darkifyDrBound = "1";
510 frame.addEventListener("load", function () {
511 applyToIframe(this, isDark());
512 });
513 }
514 applyToIframe(frame, enabled);
515 }
516 }
517
518 /*
519 * The canvas iframe is mounted asynchronously, well after this script runs,
520 * and is replaced when the editor switches between visual and code view. One
521 * cheap querySelector per frame is enough to notice; the rAF gate keeps the
522 * editor's constant DOM churn from turning that into per-mutation work.
523 */
524 var scanScheduled = false;
525
526 function scheduleIframeScan() {
527 if (scanScheduled) {
528 return;
529 }
530 scanScheduled = true;
531 requestAnimationFrame(function () {
532 scanScheduled = false;
533 if (document.querySelector(IFRAME_SELECTOR)) {
534 applyToAllIframes(isDark());
535 }
536 });
537 }
538
539 var frameWatcher = null;
540
541 function watchForIframes() {
542 // Reachable from both boot paths and from every toggle; a second observer
543 // on the same body would double every scan for no benefit.
544 if (frameWatcher || !document.body) {
545 return;
546 }
547 frameWatcher = new MutationObserver(scheduleIframeScan);
548 frameWatcher.observe(document.body, {
549 childList: true,
550 subtree: true,
551 });
552 }
553
554 /* ---------------------------------------------------------------------- */
555 /* Apply */
556 /* ---------------------------------------------------------------------- */
557
558 /*
559 * Darkify's own screens are themed by their own design tokens, not by Dark
560 * Reader, so a chosen palette has to reach them a different way: by writing
561 * the palette's colours into those tokens directly.
562 *
563 * The map is deliberately partial. Four groups are left alone:
564 *
565 * --destructive a delete button that stops being red
566 * stops communicating danger.
567 * --chart-* categorical colours; they have to stay
568 * distinguishable from each other.
569 *
570 * --primary and --sidebar-primary ARE mapped, and the pairing that made them
571 * look risky is what makes them safe: they take `btn_bg` and `btn_text_color`,
572 * which is the palette's own button fill and its text — a pair its author
573 * already chose to be readable together. Taking both halves from that one pair
574 * is a different thing from overwriting half of shadcn's. Without them the
575 * switches, primary buttons and selected states kept a near-white default and
576 * were the only parts of the screen a chosen palette never reached.
577 */
578 var SELF_THEMED_TOKENS = {
579 "--background": "bg",
580 "--card": "secondary_bg",
581 "--popover": "secondary_bg",
582 "--sidebar": "secondary_bg",
583 "--secondary": "secondary_bg",
584 "--muted": "secondary_bg",
585 /*
586 * Accent is the "one step lighter than the surface" slot: hovered menu
587 * items, the selected sidebar entry. Handing it `secondary_bg` — the same
588 * value --card/--popover/--sidebar already take — made the active nav item
589 * disappear the moment a palette was chosen, while `auto` (no palette, Dark
590 * Reader deriving each colour) kept it visible. `border_color` is the
591 * palette's own next step up from its surfaces, which is the relation the
592 * app's own dark tokens have (--sidebar 0.205 vs --sidebar-accent 0.269).
593 */
594 "--accent": "border_color",
595 "--sidebar-accent": "border_color",
596 "--foreground": "text_color",
597 "--card-foreground": "text_color",
598 "--popover-foreground": "text_color",
599 "--secondary-foreground": "text_color",
600 "--accent-foreground": "text_color",
601 "--sidebar-foreground": "text_color",
602 "--sidebar-accent-foreground": "text_color",
603 "--muted-foreground": "input_text_color",
604 "--border": "border_color",
605 "--input": "border_color",
606 "--sidebar-border": "border_color",
607 "--ring": "link_color",
608 "--primary": "btn_bg",
609 "--primary-foreground": "btn_text_color",
610 "--sidebar-primary": "btn_bg",
611 "--sidebar-primary-foreground": "btn_text_color",
612 };
613
614 /**
615 * Push the active palette into the self-themed app's tokens, or clear them.
616 *
617 * Cleared on "auto" and in light mode alike, which hands the app back to the
618 * tokens its own stylesheet defines rather than leaving a half-applied
619 * palette behind.
620 */
621 function applySelfThemedTokens() {
622 var p = isDark() ? currentPalette() : null;
623
624 for (var token in SELF_THEMED_TOKENS) {
625 if (!Object.prototype.hasOwnProperty.call(SELF_THEMED_TOKENS, token)) {
626 continue;
627 }
628 var value = p ? p[SELF_THEMED_TOKENS[token]] : null;
629
630 /*
631 * A missing field clears the token rather than writing it.
632 *
633 * setProperty() stringifies whatever it is handed, so a key this palette
634 * does not carry became the literal text "undefined" — a custom property
635 * that parses but can never resolve. Every `var()` reading it then failed,
636 * and a failed var() takes its whole declaration with it: the switch track
637 * did not fall back to a default colour, it lost its background entirely
638 * and rendered transparent. Clearing instead lets the stylesheet's own
639 * value stand, which is wrong-looking at worst rather than invisible.
640 */
641 if (value) {
642 HTML.style.setProperty(token, value);
643 } else {
644 HTML.style.removeProperty(token);
645 }
646 }
647 }
648
649 /*
650 * Erase the horizontal seam near the bottom of short admin pages.
651 *
652 * wp-admin paints `body { background: #f0f0f1 }` and leaves <html> unpainted.
653 * Dark Reader derives body's colour from #f0f0f1 but paints <html> with
654 * darkSchemeBackgroundColor — a different value. When the content is shorter
655 * than the viewport, <html> shows below <body> and the join is visible. The
656 * admin menu column ends at the same line, which is what makes the seam run
657 * the full width of the page.
658 *
659 * Declaring `html { background-color: ${#f0f0f1} }` in fixes.css does not win:
660 * Dark Reader's own generated rule for the root element is `!important` too,
661 * and it is the one that applies. So rather than predicting the colour, read
662 * back the one it actually produced for <body> and pin <html> to it inline —
663 * an inline `!important` outranks any stylesheet, including its own.
664 *
665 * Runs twice on purpose. Dark Reader processes stylesheets as it finds them,
666 * and a sheet that arrives late (an admin page loading its own CSS) can change
667 * what body resolves to after the first read.
668 */
669 function syncRootBackground() {
670 if (!document.body) {
671 return;
672 }
673
674 var color = window.getComputedStyle(document.body).backgroundColor;
675
676 // Transparent means body is not painting anything of its own, so there is no
677 // second colour to reconcile and nothing to correct.
678 if (!color || color === "transparent" || color === "rgba(0, 0, 0, 0)") {
679 HTML.style.removeProperty("background-color");
680 return;
681 }
682
683 HTML.style.setProperty("background-color", color, "important");
684 }
685
686 function scheduleRootBackgroundSync() {
687 if (typeof requestAnimationFrame === "function") {
688 requestAnimationFrame(syncRootBackground);
689 } else {
690 setTimeout(syncRootBackground, 0);
691 }
692 setTimeout(syncRootBackground, 300);
693 }
694
695 function apply() {
696 var enabled = isDark();
697
698 if (isSelfThemedScreen()) {
699 // The page's own CSS does the painting here; the palette reaches it
700 // through its tokens. Disable defensively in case a previous navigation
701 // on this document had Dark Reader on.
702 DarkReader.disable();
703 applySelfThemedTokens();
704 return;
705 }
706
707 if (enabled) {
708 DarkReader.enable(buildTheme(), buildFixes());
709 } else {
710 DarkReader.disable();
711 }
712
713 if (enabled) {
714 scheduleRootBackgroundSync();
715 } else {
716 HTML.style.removeProperty("background-color");
717 }
718
719 applyToAllIframes(enabled);
720 watchForIframes();
721 }
722
723 /**
724 * Keep the palette class on <html> in step with the frontend engine's
725 * convention, so CSS that keys off it (the switch, the admin bar icon) still
726 * matches. "auto" gets no class — there is no palette to name.
727 */
728 function applyPaletteClass() {
729 var classes = Array.prototype.slice.call(HTML.classList);
730 for (var i = 0; i < classes.length; i++) {
731 if (classes[i].indexOf("darkify-set") === 0) {
732 HTML.classList.remove(classes[i]);
733 }
734 }
735
736 var name = currentPaletteName();
737 if (name !== "auto") {
738 HTML.classList.add("darkify-" + name);
739 }
740 }
741
742 /* ---------------------------------------------------------------------- */
743 /* Public API (names are load-bearing — see the file header) */
744 /* ---------------------------------------------------------------------- */
745
746 window.darkify_switch_trigger = function () {
747 if (!optionEnabled()) {
748 return;
749 }
750 HTML.classList.toggle(DARK_CLASS);
751 writeState(isDark());
752 apply();
753 };
754
755 window.darkify_theme_select = function (theme) {
756 // "auto" is a valid choice, not a missing one: it hands the colours back to
757 // Dark Reader's derivation.
758 if (isPaletteValue(theme)) {
759 try {
760 localStorage.setItem(paletteKey(), theme);
761 } catch (e) {
762 // not remembered, still applied below
763 }
764 }
765
766 applyPaletteClass();
767 apply();
768 };
769
770 /** Exposed for support: what the engine thinks it is doing right now. */
771 window.darkifyAdminEngine = {
772 engine: "darkreader",
773 version: typeof DarkReader.getVersion === "function" ? DarkReader.getVersion() : null,
774 isDark: isDark,
775 palette: currentPaletteName,
776 paletteKey: paletteKey,
777 selfThemed: isSelfThemedScreen,
778 palettes: function () {
779 return ["auto"].concat(Object.keys(PALETTES));
780 },
781 theme: buildTheme,
782 fixes: buildFixes,
783 reapply: apply,
784
785 /**
786 * Preview unsaved palette choices.
787 *
788 * @param {{admin?: string, editor?: string}|null} overrides
789 * Palette ids to show, or null to drop back to saved values.
790 */
791 preview: function (overrides) {
792 previewOverrides = overrides || null;
793 applyPaletteClass();
794 apply();
795 },
796 };
797
798 /* ---------------------------------------------------------------------- */
799 /* Boot */
800 /* ---------------------------------------------------------------------- */
801
802 function init() {
803 // Cross-origin admin stylesheets (a CDN-hosted plugin sheet) are otherwise
804 // skipped, leaving patches of the admin un-darkened.
805 try {
806 DarkReader.setFetchMethod(window.fetch.bind(window));
807 } catch (e) {
808 // ignore
809 }
810
811 var on = optionEnabled() && readState();
812
813 if (on) {
814 HTML.classList.add(DARK_CLASS);
815 applyPaletteClass();
816 } else {
817 HTML.classList.remove(DARK_CLASS);
818 }
819
820 if (on && !isSelfThemedScreen()) {
821 DarkReader.enable(buildTheme(), buildFixes());
822 } else {
823 DarkReader.disable();
824 }
825
826 if (isSelfThemedScreen()) {
827 applySelfThemedTokens();
828 } else if (on) {
829 // init() runs in <head>, where there is no <body> to measure yet.
830 if (document.readyState === "loading") {
831 document.addEventListener("DOMContentLoaded", scheduleRootBackgroundSync);
832 } else {
833 scheduleRootBackgroundSync();
834 }
835 }
836 }
837
838 /*
839 * Two-stage boot, and the split is the whole point.
840 *
841 * The paint has to be claimed before the browser makes one: this script is
842 * printed in <head>, and Dark Reader is built to run there — enable() does not
843 * need a parsed body. Waiting for DOMContentLoaded to call it would let the
844 * light admin paint first and then swap, which is exactly the flash the
845 * pre-paint snippet in header_script.php exists to prevent.
846 *
847 * The iframe work genuinely does need a body to query and observe, so only
848 * that half waits.
849 */
850 init();
851
852 if (document.readyState === "loading") {
853 document.addEventListener("DOMContentLoaded", initFrames);
854 } else {
855 initFrames();
856 }
857
858 function initFrames() {
859 if (isSelfThemedScreen()) {
860 return;
861 }
862
863 /*
864 * init() ran in <head>, where there is no <body> to test — so
865 * isEditorContext() was necessarily false and the page was themed with the
866 * admin palette. On the block editor that is the wrong one, and it would
867 * have left the editor's chrome on the admin palette while only the canvas
868 * picked up the editor's. Now that the body exists the context is knowable,
869 * so re-theme the page before touching the frames.
870 */
871 if (isEditorContext()) {
872 apply();
873 return; // apply() reaches the frames itself
874 }
875
876 applyToAllIframes(isDark());
877 watchForIframes();
878 }
879 })();
880