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.