PageSourceSearch

https://www.writinglaw.com/wp-content/themes/theme3115_output/js/darkmode.js?ver=17.0.0

js writinglaw.com collected 2026-09-25 02:40:41 UTC 21,278 bytes, 600 lines download raw bytes

1/**
2 * Dark Mode — WL Custom Theme
3 * Strict mode (same engine as the Smart Dark Mode plugin this replaces):
4 *   • Only converts pure white backgrounds (#ffffff) -> dark bg colour
5 *   • Only converts #000000 and #333333 text -> #ffffff
6 *   • Any element with a non-pure-white background: left alone
7 *   • Admin-configurable override lists from Customize > Dark Mode:
8 *       ignoreColors     - skip these bg/text colours entirely (+ children's text)
9 *       forceWhiteColors - force these text colours -> #ffffff
10 *       forceBgColors    - force these bg colours -> dark bg colour
11 *       colorRemaps      - remap specific colours to other specific colours
12 *
13 * The toggle icon(s) are static inline nav items (desktop + mobile), not a
14 * floating button, so there is no placement/visibility logic here — only
15 * the colour engine and simple click wiring.
16 *
17 * ⚠ DEPENDED ON BY THE "WL Membership" PLUGIN — see the matching warning
18 * block at the top of include/darkmode.php before renaming/removing the
19 * `data-wlcustom-dm` attribute, the `--wlcustom-dm-bg`/`--wlcustom-dm-text`
20 * CSS vars, `window.wlcustomDMConfig.bgColor`, or the body class/style
21 * MutationObserver below (setupMutationObserver()). healStaleWhiteText()
22 * specifically exists to let that plugin's custom reading-background
23 * colours (and any similar future feature) update correctly without its
24 * own JS having to walk the whole article on every change — see its own
25 * comment further down for how that mechanism works.
26 */
27(function () {
28	'use strict';
29
30	var CFG  = window.wlcustomDMConfig || {};
31	var KEY  = 'wlcustom-dm';
32	var html = document.documentElement;
33
34	/* -------------------------------------------------
35	   Parse admin hex lists from config
36	------------------------------------------------- */
37	function parseHexList(str) {
38		if (!str) return [];
39		var out = [];
40		var parts = str.split(',');
41		for (var i = 0; i < parts.length; i++) {
42			var h = parts[i].replace(/\s+/g, '').toLowerCase();
43			if (/^#[0-9a-f]{6}$/.test(h)) out.push(h);
44		}
45		return out;
46	}
47
48	var LIST_IGNORE      = parseHexList(CFG.ignoreColors);
49	var LIST_FORCE_WHITE = parseHexList(CFG.forceWhiteColors);
50	var LIST_FORCE_BG    = parseHexList(CFG.forceBgColors);
51
52	function parseRemapList(str) {
53		if (!str) return [];
54		var out   = [];
55		var pairs = str.split(',');
56		for (var i = 0; i < pairs.length; i++) {
57			var parts = pairs[i].replace(/\s+/g,'').toLowerCase().split('>');
58			if (parts.length === 2 && /^#[0-9a-f]{6}$/.test(parts[0]) && /^#[0-9a-f]{6}$/.test(parts[1])) {
59				out.push({ from: parts[0], to: parts[1] });
60			}
61		}
62		return out;
63	}
64
65	var LIST_REMAPS = parseRemapList(CFG.colorRemaps);
66
67	function rgbToHex(c) {
68		if (!c) return '';
69		function h(n) { var s = Math.round(n).toString(16); return s.length === 1 ? '0' + s : s; }
70		return '#' + h(c.r) + h(c.g) + h(c.b);
71	}
72
73	function hexToRGB(hex) {
74		var r = parseInt(hex.slice(1,3), 16);
75		var g = parseInt(hex.slice(3,5), 16);
76		var b = parseInt(hex.slice(5,7), 16);
77		return { r: r, g: g, b: b };
78	}
79
80	function colorMatchesHex(c, hex) {
81		var t = hexToRGB(hex);
82		return Math.abs(c.r - t.r) <= 1 &&
83		       Math.abs(c.g - t.g) <= 1 &&
84		       Math.abs(c.b - t.b) <= 1;
85	}
86
87	function remapColor(c) {
88		if (!LIST_REMAPS.length || !c) return null;
89		for (var i = 0; i < LIST_REMAPS.length; i++) {
90			if (colorMatchesHex(c, LIST_REMAPS[i].from)) return LIST_REMAPS[i].to;
91		}
92		return null;
93	}
94
95	function inList(list, c) {
96		if (!list.length || !c) return false;
97		for (var i = 0; i < list.length; i++) { if (colorMatchesHex(c, list[i])) return true; }
98		return false;
99	}
100
101	/* -------------------------------------------------
102	   Colour helpers
103	------------------------------------------------- */
104	function parseRGB(str) {
105		if (!str) return null;
106		var m = str.replace(/\s+/g, '').match(/rgba?\((\d+),(\d+),(\d+)(?:,([\d.]+))?\)/);
107		if (!m) return null;
108		var a = (m[4] !== undefined) ? parseFloat(m[4]) : 1;
109		return { r: +m[1], g: +m[2], b: +m[3], a: a };
110	}
111
112	function isTransparent(str) {
113		if (!str || str === 'transparent') return true;
114		if (str.indexOf('rgba') !== -1 && str.indexOf(',0)') !== -1)  return true;
115		if (str.indexOf('rgba') !== -1 && str.indexOf(', 0)') !== -1) return true;
116		return false;
117	}
118
119	function isPureWhite(c) {
120		return c && c.r === 255 && c.g === 255 && c.b === 255 && c.a >= 0.99;
121	}
122
123	function isPureBlack(c) {
124		if (!c) return false;
125		var spread = Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b);
126		return c.r <= 5 && c.g <= 5 && c.b <= 5 && spread <= 5;
127	}
128
129	function isDarkNearBlack(c) {
130		if (!c) return false;
131		var spread = Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b);
132		return c.r >= 45 && c.r <= 60 &&
133		       c.g >= 45 && c.g <= 60 &&
134		       c.b >= 45 && c.b <= 60 &&
135		       spread <= 10;
136	}
137
138	/* -------------------------------------------------
139	   Safe closest() — works on all Safari versions
140	------------------------------------------------- */
141	function safeClosest(el, selector) {
142		try {
143			if (el && typeof el.closest === 'function') return el.closest(selector);
144		} catch (e) {}
145		var node = el;
146		while (node && node.nodeType === 1 && node !== document.documentElement) {
147			try { if (node.matches && node.matches(selector)) return node; } catch (e) {}
148			node = node.parentElement;
149		}
150		return null;
151	}
152
153	function isInInteractive(el, tag) {
154		if (tag === 'BUTTON' || tag === 'A' || tag === 'LABEL' ||
155			tag === 'INPUT'  || tag === 'SELECT' || tag === 'TEXTAREA') return true;
156		if (safeClosest(el, 'button')) return true;
157		if (safeClosest(el, 'a'))      return true;
158		if (safeClosest(el, '[role="button"]')) return true;
159		return false;
160	}
161
162	/* -------------------------------------------------
163	   Save / restore for toggle-off
164	------------------------------------------------- */
165	var saved = [];
166
167	function saveAndSet(el, prop, value) {
168		try {
169			saved.push({ el: el, prop: prop, original: el.style[prop] || '' });
170			el.style.setProperty(prop, value, 'important');
171		} catch (e) {
172			try {
173				saved.push({ el: el, prop: prop, original: el.style[prop] || '' });
174				el.style[prop] = value;
175			} catch (e2) {}
176		}
177	}
178
179	function restoreAll() {
180		for (var i = 0; i < saved.length; i++) {
181			var s = saved[i];
182			try {
183				if (!s.original) s.el.style.removeProperty(s.prop);
184				else s.el.style[s.prop] = s.original;
185			} catch (e) {}
186		}
187		saved = [];
188	}
189
190	/* -------------------------------------------------
191	   Self-heal — undo stale white-text forcing
192	   -----------------------------------------------------------------------
193	   Everything above is purely additive: once an element's text is forced
194	   white, nothing ever reconsiders it later. That's fine as long as the
195	   page around it doesn't change — but if something else (a plugin, a
196	   widget, a block) gives an element a genuinely different background
197	   colour AFTER Dark Mode already ran (e.g. a custom "reading
198	   background" card), the earlier "this was near-black text on a white
199	   background, so make it white" decision goes stale: the text is still
200	   forced white, but it's now sitting on a background this engine never
201	   itself darkened, and can easily become unreadable.
202	   This only reconsiders elements whose CURRENT background is neither
203	   pure white (nothing to undo — the earlier decision still holds) nor
204	   this engine's own dark background colour (that background WAS
205	   deliberately darkened by this engine — the white text there is
206	   correct and must stay, which is why this never fights normal Dark
207	   Mode styling elsewhere on the site). Only a genuinely different,
208	   third-party background colour triggers a restore. Restoring simply
209	   removes the forced inline colour so the page's normal CSS (including
210	   any such third-party rule) decides the real colour — the ordinary
211	   near-black auto-detection a little further down then gets a fair,
212	   fresh look at that real colour against the real background, exactly
213	   like it would on first page load.
214	   Only called from rescanElement(), never from the very first
215	   applyDark() pass — there's nothing to heal yet at that point. Note
216	   this only re-examines elements within the CURRENT rescan's target
217	   subtree (see rescanElement()), not the whole page each time; a
218	   change elsewhere later mutation naturally catches up any others. */
219	function healStaleWhiteText(candidates) {
220		var darkBg = CFG.bgColor || '#0a1423';
221
222		// O(1) membership check instead of candidates.indexOf(el) in a loop
223		// (that would be O(saved.length * candidates.length) on a big page).
224		// Marker is removed again right after, so nothing leaks.
225		for (var m = 0; m < candidates.length; m++) candidates[m].__wlcustomDMHealCandidate = true;
226
227		for (var i = saved.length - 1; i >= 0; i--) {
228			var s = saved[i];
229			if (s.prop !== 'color') continue;
230			var el = s.el;
231			if (!el || !el.isConnected || !el.__wlcustomDMHealCandidate) continue;
232
233			var cs;
234			try { cs = window.getComputedStyle(el); } catch (e) { continue; }
235			var curColor = parseRGB(cs.color);
236			if (!curColor || !isPureWhite(curColor)) continue; // not currently forced-white — nothing to reconsider
237
238			var bg = null;
239			try { var bgStr = cs.backgroundColor; if (!isTransparent(bgStr)) bg = parseRGB(bgStr); } catch (e) {}
240			var effBg = bg || findAncestorBg(el, [], []);
241			if (!effBg) continue;                          // no background found — most likely still inheriting this engine's own dark backdrop
242			if (isPureWhite(effBg)) continue;               // shouldn't happen for forced-white text, but guard anyway
243			if (colorMatchesHex(effBg, darkBg)) continue;   // this IS this engine's own dark background — correctly white, leave it
244			if (inList(LIST_IGNORE, effBg)) continue;       // admin explicitly wants this background left untouched
245
246			try {
247				if (!s.original) el.style.removeProperty('color');
248				else el.style.color = s.original;
249			} catch (e) {}
250			saved.splice(i, 1);
251		}
252
253		for (var n = 0; n < candidates.length; n++) delete candidates[n].__wlcustomDMHealCandidate;
254	}
255
256	/* -------------------------------------------------
257	   PASS 1 — Snapshot all colours before touching anything
258	------------------------------------------------- */
259	function snapshotAll(allEls) {
260		var snap     = [];
261		var indexMap = [];
262
263		for (var i = 0; i < allEls.length; i++) {
264			var el = allEls[i];
265			if (!el || el.nodeType !== 1) continue;
266			var tag = (el.tagName || '').toUpperCase();
267			if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'META' ||
268				tag === 'LINK'   || tag === 'NOSCRIPT') continue;
269
270			var cs;
271			try { cs = window.getComputedStyle(el); } catch(e) { continue; }
272
273			var bg = null, color = null;
274			try { var bgStr = cs.backgroundColor; if (!isTransparent(bgStr)) bg = parseRGB(bgStr); } catch(e) {}
275			try { color = parseRGB(cs.color); } catch(e) {}
276
277			snap.push({ el: el, tag: tag, bg: bg, color: color, effectiveBg: null });
278			indexMap.push(el);
279		}
280
281		for (var j = 0; j < snap.length; j++) {
282			snap[j].effectiveBg = findAncestorBg(snap[j].el, snap, indexMap);
283		}
284
285		return snap;
286	}
287
288	function findAncestorBg(el, snap, indexMap) {
289		var node = el.parentElement;
290		while (node && node !== document.documentElement) {
291			var idx = indexMap.indexOf(node);
292			if (idx !== -1 && snap[idx].bg) return snap[idx].bg;
293			try {
294				var rawBg = window.getComputedStyle(node).backgroundColor;
295				if (!isTransparent(rawBg)) return parseRGB(rawBg);
296			} catch(e) {}
297			node = node.parentElement;
298		}
299		return null;
300	}
301
302	/* -------------------------------------------------
303	   PASS 2 — Apply using snapshot
304	------------------------------------------------- */
305	function applyDark() {
306		var darkBg    = CFG.bgColor || '#0a1423';
307		var whiteText = '#ffffff';
308
309		html.style.setProperty('--wlcustom-dm-bg', darkBg);
310
311		var allEls;
312		try { allEls = document.querySelectorAll('*'); } catch(e) { return; }
313
314		var snap = snapshotAll(allEls);
315
316		for (var i = 0; i < snap.length; i++) {
317			var entry       = snap[i];
318			var el          = entry.el;
319			var tag         = entry.tag;
320			var ownBg       = entry.bg;
321			var effectiveBg = entry.effectiveBg;
322
323			if (isToggleEl(el)) continue;
324
325			var remappedBg   = ownBg    ? remapColor(ownBg)       : null;
326			var remappedText = entry.color ? remapColor(entry.color) : null;
327			if (remappedBg !== null) {
328				saveAndSet(el, 'background-color', remappedBg);
329				saveAndSet(el, 'background-image', 'none');
330			}
331			if (remappedText !== null) {
332				saveAndSet(el, 'color', remappedText);
333			}
334
335			if (inList(LIST_IGNORE, ownBg) || inList(LIST_IGNORE, entry.color)) continue;
336			if (inList(LIST_IGNORE, effectiveBg)) continue;
337
338			if (ownBg) {
339				if (inList(LIST_FORCE_BG, ownBg)) {
340					saveAndSet(el, 'background-color', darkBg);
341					saveAndSet(el, 'background-image', 'none');
342				} else if (isPureWhite(ownBg)) {
343					saveAndSet(el, 'background-color', darkBg);
344					saveAndSet(el, 'background-image', 'none');
345				}
346			}
347
348			if (entry.color) {
349				if (inList(LIST_FORCE_WHITE, entry.color)) {
350					saveAndSet(el, 'color', whiteText);
351				} else if (!isInInteractive(el, tag) && (isPureBlack(entry.color) || isDarkNearBlack(entry.color))) {
352					var bgBehind = ownBg || effectiveBg;
353					if (bgBehind && !isPureWhite(bgBehind)) continue;
354					saveAndSet(el, 'color', whiteText);
355				}
356			}
357		}
358	}
359
360	function removeDark() {
361		html.style.removeProperty('--wlcustom-dm-bg');
362		restoreAll();
363	}
364
365	/* Toggle buttons (and their icon spans) are never recoloured by the engine. */
366	function isToggleEl(el) {
367		return !!safeClosest(el, '.wlcustom-dm-toggle');
368	}
369
370	/* -------------------------------------------------
371	   Re-scan after dynamic DOM changes
372	------------------------------------------------- */
373	function rescanElement(root) {
374		if (!isDarkActive()) return;
375		if (!root || root.nodeType !== 1) return;
376
377		var darkBg    = CFG.bgColor || '#0a1423';
378		var whiteText = '#ffffff';
379
380		var targets = [];
381		try {
382			targets.push(root);
383			var children = root.querySelectorAll ? root.querySelectorAll('*') : [];
384			for (var i = 0; i < children.length; i++) targets.push(children[i]);
385		} catch(e) { return; }
386
387		// Reconsider any already-forced-white text within this subtree whose
388		// background has genuinely changed since it was forced — see
389		// healStaleWhiteText() above. Must run before snapshotAll() below so
390		// the forward pass sees each element's real (just-restored) colour,
391		// not the stale forced one.
392		healStaleWhiteText(targets);
393
394		var snap = snapshotAll(targets);
395
396		for (var j = 0; j < snap.length; j++) {
397			var entry = snap[j];
398			var el    = entry.el;
399			var tag   = entry.tag;
400			if (isToggleEl(el)) continue;
401
402			var rBg   = entry.bg    ? remapColor(entry.bg)    : null;
403			var rText = entry.color ? remapColor(entry.color) : null;
404			if (rBg !== null) {
405				saveAndSet(el, 'background-color', rBg);
406				saveAndSet(el, 'background-image', 'none');
407			}
408			if (rText !== null) {
409				saveAndSet(el, 'color', rText);
410			}
411
412			if (inList(LIST_IGNORE, entry.bg) || inList(LIST_IGNORE, entry.color)) continue;
413			if (inList(LIST_IGNORE, entry.effectiveBg)) continue;
414
415			if (entry.bg) {
416				if (inList(LIST_FORCE_BG, entry.bg)) {
417					saveAndSet(el, 'background-color', darkBg);
418					saveAndSet(el, 'background-image', 'none');
419				} else if (isPureWhite(entry.bg)) {
420					saveAndSet(el, 'background-color', darkBg);
421					saveAndSet(el, 'background-image', 'none');
422				}
423			}
424
425			if (entry.color) {
426				if (inList(LIST_FORCE_WHITE, entry.color)) {
427					saveAndSet(el, 'color', whiteText);
428				} else if (!isInInteractive(el, tag) && (isPureBlack(entry.color) || isDarkNearBlack(entry.color))) {
429					var bgBehind = entry.bg || entry.effectiveBg;
430					if (bgBehind && !isPureWhite(bgBehind)) continue;
431					saveAndSet(el, 'color', whiteText);
432				}
433			}
434		}
435	}
436
437	/* -------------------------------------------------
438	   MutationObserver for dynamic widgets
439	------------------------------------------------- */
440	function setupMutationObserver() {
441		if (!window.MutationObserver) return;
442		var debounceTimer = null;
443		var pendingNodes  = [];
444
445		var observer = new MutationObserver(function(mutations) {
446			if (!isDarkActive()) return;
447			for (var i = 0; i < mutations.length; i++) {
448				var m = mutations[i];
449				if (m.type === 'attributes') pendingNodes.push(m.target);
450				if (m.type === 'childList') {
451					for (var j = 0; j < m.addedNodes.length; j++) {
452						if (m.addedNodes[j].nodeType === 1) pendingNodes.push(m.addedNodes[j]);
453					}
454				}
455			}
456			if (debounceTimer) clearTimeout(debounceTimer);
457			debounceTimer = setTimeout(function() {
458				var nodes = pendingNodes.slice();
459				pendingNodes = [];
460				for (var k = 0; k < nodes.length; k++) {
461					try { rescanElement(nodes[k]); } catch(e) {}
462				}
463				debounceTimer = null;
464			}, 50);
465		});
466
467		try {
468			observer.observe(document.body, {
469				childList: true, subtree: true,
470				attributes: true, attributeFilter: ['class', 'style']
471			});
472		} catch(e) {}
473	}
474
475	/* -------------------------------------------------
476	   Toggle UI — keeps every toggle button (desktop + mobile) in sync
477	------------------------------------------------- */
478	function updateToggleUI(active) {
479		var toggles = document.querySelectorAll('[data-wlcustom-dm-toggle]');
480		for (var i = 0; i < toggles.length; i++) {
481			toggles[i].setAttribute('aria-pressed', active ? 'true' : 'false');
482		}
483	}
484
485	/* -------------------------------------------------
486	   Core set / unset — no transitions during the switch,
487	   so nothing visibly shifts or jitters.
488	------------------------------------------------- */
489	function withNoTransition(fn) {
490		var style = document.createElement('style');
491		style.id  = 'wlcustom-dm-no-transition';
492		style.textContent = '*, *::before, *::after { transition: none !important; animation: none !important; }';
493		document.head.appendChild(style);
494		fn();
495		void document.body.offsetHeight;
496		document.head.removeChild(style);
497	}
498
499	function setDarkMode(active) {
500		withNoTransition(function() {
501			if (active) {
502				html.setAttribute('data-wlcustom-dm', 'dark');
503				try {
504					localStorage.setItem(KEY, '1');
505					localStorage.setItem(KEY + '-ts', String(Date.now()));
506				} catch(e) {}
507				applyDark();
508			} else {
509				html.removeAttribute('data-wlcustom-dm');
510				try {
511					localStorage.removeItem(KEY);
512					localStorage.removeItem(KEY + '-ts');
513				} catch(e) {}
514				removeDark();
515			}
516			try { html.style.colorScheme = active ? 'dark' : 'light'; } catch(e) {}
517		});
518		updateToggleUI(active);
519	}
520
521	function isDarkActive() { return html.getAttribute('data-wlcustom-dm') === 'dark'; }
522	/* Falls back to 8 hours only if the config failed to load for some reason;
523	   normally this comes straight from Customizer > Dark Mode via PHP, so
524	   there's a single source of truth (see WLCUSTOM_DARKMODE_EXPIRY_HOURS
525	   in include/darkmode.php) shared with the anti-FOUC script in wp_head. */
526	var EXPIRY_MS = (CFG.expiryMs && CFG.expiryMs > 0) ? CFG.expiryMs : (8 * 60 * 60 * 1000);
527
528	function getSavedPref() {
529		try {
530			var val = localStorage.getItem(KEY);
531			if (val !== '1') return val;
532			var ts = localStorage.getItem(KEY + '-ts');
533			if (ts && (Date.now() - parseInt(ts, 10)) > EXPIRY_MS) {
534				localStorage.removeItem(KEY);
535				localStorage.removeItem(KEY + '-ts');
536				return null;
537			}
538			return '1';
539		} catch(e) { return null; }
540	}
541
542	function systemPrefersDark() {
543		try { return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; }
544		catch(e) { return false; }
545	}
546
547	/* -------------------------------------------------
548	   Init
549	------------------------------------------------- */
550	function init() {
551		var pref         = getSavedPref();
552		var followSystem = CFG.followSystem === 1 || CFG.followSystem === '1';
553		var initialDark  = (pref !== null) ? (pref === '1') : (followSystem && systemPrefersDark());
554
555		if (initialDark) {
556			html.setAttribute('data-wlcustom-dm', 'dark');
557			try { html.style.colorScheme = 'dark'; } catch(e) {}
558			applyDark();
559			updateToggleUI(true);
560		} else {
561			html.removeAttribute('data-wlcustom-dm');
562			try { html.style.colorScheme = 'light'; } catch(e) {}
563			updateToggleUI(false);
564		}
565
566		/* Reveal the page now that colours are correct — see the
567		   wlcustom-dm-pending class added by the anti-FOUC script in
568		   wp_head. Safe to call even when that class was never added. */
569		html.classList.remove('wlcustom-dm-pending');
570
571		var toggles = document.querySelectorAll('[data-wlcustom-dm-toggle]');
572		for (var i = 0; i < toggles.length; i++) {
573			(function(toggle){
574				toggle.setAttribute('tabindex', '0');
575				toggle.setAttribute('role', 'button');
576				toggle.addEventListener('click', function() { setDarkMode(!isDarkActive()); });
577				toggle.addEventListener('keydown', function(e) {
578					if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle.click(); }
579				});
580			})(toggles[i]);
581		}
582
583		if (followSystem) {
584			try {
585				window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
586					if (getSavedPref() === null) setDarkMode(e.matches);
587				});
588			} catch(e) {}
589		}
590
591		setupMutationObserver();
592	}
593
594	if (document.readyState === 'loading') {
595		document.addEventListener('DOMContentLoaded', init);
596	} else {
597		init();
598	}
599
600})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.