1// sys-user-prefs.js - Reader Personalization Widget 2// Self-contained, no dependencies. Uses Font Awesome already loaded by site. 3(function() { 4 'use strict'; 5 6 // Ensure custom.css is loaded (covers old pre-generated pages that lack the static <link>) 7 if (!document.querySelector('link[href*="custom.css"]')) { 8 var _cl = document.createElement('link'); 9 _cl.rel = 'stylesheet'; 10 _cl.href = '/custom.css'; 11 document.head.appendChild(_cl); 12 } 13 14 var STORAGE_KEY = 'cms_user_prefs'; 15 var PREFIX = 'sys-up'; 16 17 // Base font sizes from sys-responsive.css :root 18 var BASE_FONTS = { base: 14, sm: 12, lg: 16, xl: 18, xxl: 24 }; 19 var BASE_SPACING = { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 }; 20 21 var THEMES = { 22 auto: { 23 label: 'Auto', icon: null 24 }, 25 light: { 26 label: 'Light', icon: 'fa-sun', 27 bg: '#ffffff', text: '#1f2937', textSecondary: '#6b7280', 28 link: '#2563eb', linkHover: '#1d4ed8', heading: '#111827', 29 border: '#e5e7eb', cardBg: '#ffffff', cardShadow: 'rgba(0,0,0,0.1)', 30 codeBg: '#f3f4f6', inputBg: '#ffffff', inputBorder: '#d1d5db', 31 navBg: '#1a1a1a', navText: '#ffffff' 32 }, 33 dark: { 34 label: 'Dark', icon: 'fa-moon', 35 bg: '#121212', text: '#e2e8f0', textSecondary: '#94a3b8', 36 link: '#60a5fa', linkHover: '#93bbfd', heading: '#f1f5f9', 37 border: '#334155', cardBg: '#1e293b', cardShadow: 'rgba(0,0,0,0.4)', 38 codeBg: '#0f172a', inputBg: '#1e293b', inputBorder: '#475569', 39 navBg: '#0f172a', navText: '#e2e8f0' 40 }, 41 sepia: { 42 label: 'Sepia', icon: 'fa-book-open', 43 bg: '#f4ecd8', text: '#5b4636', textSecondary: '#8b7355', 44 link: '#8b4513', linkHover: '#a0522d', heading: '#3e2723', 45 border: '#d4c5a9', cardBg: '#faf6ed', cardShadow: 'rgba(91,70,54,0.1)', 46 codeBg: '#ede4d0', inputBg: '#faf6ed', inputBorder: '#c4b590', 47 navBg: '#5b4636', navText: '#f4ecd8' 48 }, 49 highContrast: { 50 label: 'Contrast', icon: 'fa-circle-half-stroke', 51 bg: '#000000', text: '#ffffff', textSecondary: '#ffffff', 52 link: '#ffff00', linkHover: '#ffcc00', heading: '#ffffff', 53 border: '#ffffff', cardBg: '#000000', cardShadow: 'none', 54 codeBg: '#1a1a1a', inputBg: '#000000', inputBorder: '#ffffff', 55 navBg: '#000000', navText: '#ffff00' 56 } 57 }; 58 59 var FONT_STEPS = [ 60 { key: 'small', label: 'A', scale: 0.85, size: '13px' }, 61 { key: 'medium', label: 'A', scale: 1.0, size: '15px' }, 62 { key: 'large', label: 'A', scale: 1.15, size: '18px' }, 63 { key: 'xlarge', label: 'A', scale: 1.3, size: '21px' } 64 ]; 65 66 var DENSITY_STEPS = [ 67 { key: 'compact', label: 'Compact', scale: 0.75 }, 68 { key: 'comfortable', label: 'Comfortable', scale: 1.0 }, 69 { key: 'spacious', label: 'Spacious', scale: 1.25 } 70 ]; 71 72 var FONT_FAMILIES = { 73 system: { label: 'System', css: "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif", google: null }, 74 arial: { label: 'Arial', css: "Arial,Helvetica,sans-serif", google: null }, 75 georgia: { label: 'Georgia', css: "Georgia,serif", google: null }, 76 times: { label: 'Times', css: "'Times New Roman',Times,serif", google: null }, 77 verdana: { label: 'Verdana', css: "Verdana,Geneva,sans-serif", google: null }, 78 tahoma: { label: 'Tahoma', css: "Tahoma,Geneva,sans-serif", google: null }, 79 trebuchet: { label: 'Trebuchet', css: "'Trebuchet MS',Helvetica,sans-serif", google: null }, 80 roboto: { label: 'Roboto', css: "Roboto,sans-serif", google: 'Roboto:wght@400;700' }, 81 opensans: { label: 'Open Sans', css: "'Open Sans',sans-serif", google: 'Open+Sans:wght@400;700' }, 82 lato: { label: 'Lato', css: "Lato,sans-serif", google: 'Lato:wght@400;700' }, 83 montserrat: { label: 'Montserrat', css: "Montserrat,sans-serif", google: 'Montserrat:wght@400;700' }, 84 poppins: { label: 'Poppins', css: "Poppins,sans-serif", google: 'Poppins:wght@400;700' }, 85 raleway: { label: 'Raleway', css: "Raleway,sans-serif", google: 'Raleway:wght@400;700' }, 86 nunito: { label: 'Nunito', css: "Nunito,sans-serif", google: 'Nunito:wght@400;700' }, 87 merriweather: { label: 'Merriweather', css: "Merriweather,serif", google: 'Merriweather:wght@400;700' }, 88 playfair: { label: 'Playfair', css: "'Playfair Display',
88serif", google: 'Playfair+Display:wght@400;700' } 89 }; 90 91 // === PREFERENCES === 92 93 function getAdminDefaults() { 94 var d = window.__CMS_APPEARANCE || {}; 95 return { 96 theme: d.theme || null, 97 fontScale: d.fontScale || 1, 98 spacingScale: d.spacingScale || 1, 99 fontFamily: d.fontFamily || 'system' 100 }; 101 } 102 103 function loadPrefs() { 104 var defaults = getAdminDefaults(); 105 try { 106 var stored = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); 107 return { 108 theme: stored.theme || defaults.theme || 'light', 109 fontScale: stored.fontScale || defaults.fontScale, 110 spacingScale: stored.spacingScale || defaults.spacingScale, 111 fontFamily: stored.fontFamily || defaults.fontFamily 112 }; 113 } catch (e) { 114 return { 115 theme: defaults.theme || 'light', 116 fontScale: defaults.fontScale, 117 spacingScale: defaults.spacingScale, 118 fontFamily: defaults.fontFamily 119 }; 120 } 121 } 122 123 function savePrefs(prefs) { 124 try { localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)); } catch (e) {} 125 } 126 127 function clearPrefs() { 128 try { localStorage.removeItem(STORAGE_KEY); } catch (e) {} 129 } 130 131 // === CSS APPLICATION === 132 133 function applyFontScale(scale) { 134 var r = document.documentElement.style; 135 r.setProperty('--font-size-base', (BASE_FONTS.base * scale) + 'px'); 136 r.setProperty('--font-size-sm', (BASE_FONTS.sm * scale) + 'px'); 137 r.setProperty('--font-size-lg', (BASE_FONTS.lg * scale) + 'px'); 138 r.setProperty('--font-size-xl', (BASE_FONTS.xl * scale) + 'px'); 139 r.setProperty('--font-size-xxl', (BASE_FONTS.xxl * scale) + 'px'); 140 // Also scale html font-size for rem/em coverage 141 document.documentElement.style.fontSize = (100 * scale) + '%'; 142 } 143 144 function applySpacingScale(scale) { 145 var r = document.documentElement.style; 146 r.setProperty('--spacing-xs', (BASE_SPACING.xs * scale) + 'px'); 147 r.setProperty('--spacing-sm', (BASE_SPACING.sm * scale) + 'px'); 148 r.setProperty('--spacing-md', (BASE_SPACING.md * scale) + 'px'); 149 r.setProperty('--spacing-lg', (BASE_SPACING.lg * scale) + 'px'); 150 r.setProperty('--spacing-xl', (BASE_SPACING.xl * scale) + 'px'); 151 152 // Also inject real CSS overrides for elements that use hardcoded spacing 153 var el = document.getElementById(PREFIX + '-spacing'); 154 if (scale === 1) { 155 if (el) el.textContent = ''; 156 return; 157 } 158 var lh = scale <= 0.75 ? 1.3 : scale >= 1.25 ? 2.0 : 1.6; 159 var css = 160 'body:not(#sys-up-panel) p,' + 161 'body:not(#sys-up-panel) li,' + 162 'body:not(#sys-up-panel) td,' + 163 'body:not(#sys-up-panel) dd,' + 164 'body:not(#sys-up-panel) blockquote { line-height: ' + lh + ' !important; }' + 165 'body:not(#sys-up-panel) p,' + 166 'body:not(#sys-up-panel) h1,' + 167 'body:not(#sys-up-panel) h2,' + 168 'body:not(#sys-up-panel) h3,' + 169 'body:not(#sys-up-panel) h4,' + 170 'body:not(#sys-up-panel) ul,' + 171 'body:not(#sys-up-panel) ol,' + 172 'body:not(#sys-up-panel) table,' + 173 'body:not(#sys-up-panel) blockquote { margin-bottom: ' + (12 * scale) + 'px !important; }' + 174 'body:not(#sys-up-panel) td,' + 175 'body:not(#sys-up-panel) th { padding: ' + (6 * scale) + 'px ' + (10 * scale) + 'px !important; }'; 176 if (!el) { 177 el = document.createElement('style'); 178 el.id = PREFIX + '-spacing'; 179 document.head.appendChild(el); 180 } 181 el.textContent = css; 182 } 183 184 function applyFontFamily(key) { 185 var entry = FONT_FAMILIES[key] || FONT_FAMILIES.system; 186 var linkEl = document.getElementById(PREFIX + '-gfont'); 187 // Load Google Font if needed, otherwise remove the link 188 if (entry.google) { 189 var href = 'https://fonts.googleapis.com/css2?family=' + entry.google + '&display=swap'; 190 if (!linkEl) { 191 linkEl = document.createElement('link'); 192 linkEl.id = PREFIX + '-gfont'; 193 linkEl.rel = 'stylesheet'; 194 document.head.appendChild(linkEl); 195 } 196 linkEl.href = href; 197 } else { 198 if (linkEl) linkEl.parentNode.removeChild(linkEl); 199 } 200 // Apply font family on body (widget is outside body, unaffected) 201 document.body.style.fontFamily = entry.css; 202 } 203 204 // Uses CSS filter approach â works with all content including document.write() and inline styles 205 // Filter is applied to <body>, widget elements live outside <body> on <html> so they're unaffected
206 // Dark/HC use invert â images re-inverted to look normal 207 // Sepia uses non-invert tint â everything gets warm tone including images (like old newspaper) 208 var THEME_FILTERS = { 209 light: '', 210 dark: 'invert(1) hue-rotate(180deg)', 211 sepia: 'sepia(0.5) saturate(1.2) brightness(0.92)', 212 highContrast: 'invert(1) hue-rotate(180deg) contrast(1.3)' 213 }; 214 // Themes that use invert need images re-inverted 215 var INVERT_THEMES = { dark: true, highContrast: true }; 216 217 function applyAdminColors() { 218 var cc = (window.__CMS_APPEARANCE || {}).colors; 219 var el = document.getElementById(PREFIX + '-admin-colors'); 220 if (!cc || (!cc.text && !cc.heading && !cc.link && !cc.background && !cc.navBg && !cc.navText && !cc.footerBg && !cc.footerText && !cc.accent && !cc.border)) { 221 if (el) el.textContent = ''; 222 return; 223 } 224 var css = ''; 225 // Text colors 226 if (cc.text) css += 'body,p,li,td,dd,span,.text-dark,.text-black,.text-muted,.pagebody,.layout_pagebody,.pagebody p,.pagebody li,.pagebody td,.pagebody span{color:' + cc.text + ' !important}'; 227 if (cc.heading) css += 'h1,h2,h3,h4,h5,h6,.pageheading,.layout_pageheading,h1.text-black,h2.text-black,h3.text-black,h1.text-dark,h2.text-dark,h3.text-dark{color:' + cc.heading + ' !important}'; 228 if (cc.link) css += 'a:not(.nav-link):not(.navbar-brand):not(.dropdown-item){color:' + cc.link + ' !important}'; 229 // Background 230 if (cc.background) css += 'body,.pagebody,.layout_pagebody{background-color:' + cc.background + ' !important}'; 231 // Navbar 232 if (cc.navBg) css += '#qm0,#qm1,.qmmc,.navbar,.cus-submenu-header,#qm0 div,#qm1 div,.qmmc div,#qm0 div a,#qm1 div a,.qmmc div a,#qm0 div td,#qm1 div td,.qmmc div td{background-color:' + cc.navBg + ' !important}'; 233 if (cc.navText) css += '#qm0 a,#qm1 a,.qmmc a,.nav-link,.navbar-brand,.dropdown-item,#qm0 div a,#qm1 div a,.qmmc div a{color:' + cc.navText + ' !important}'; 234 // Footer 235 if (cc.footerBg) css += '.cus-submenu-footer{background-color:' + cc.footerBg + ' !important}'; 236 if (cc.footerText) css += '.cus-submenu-footer,.cus-submenu-footer a,.cus-submenu-footer p,.cus-submenu-footer span,.cus-submenu-footer li{color:' + cc.footerText + ' !important}'; 237 // Accent 238 if (cc.accent) css += '.btn-primary,.btn-primary:hover{background-color:' + cc.accent + ' !important;border-color:' + cc.accent + ' !important}.read-more,.badge-primary,.page-item.active .page-link{background-color:' + cc.accent + ' !important}.cus-scroll-top{background-color:' + cc.accent + ' !important}'; 239 // Borders 240 if (cc.border) css += 'hr,.card,.border{border-color:' + cc.border + ' !important}table td,table th{border-color:' + cc.border + ' !important}'; 241 if (!el) { 242 el = document.createElement('style'); 243 el.id = PREFIX + '-admin-colors'; 244 document.head.appendChild(el); 245 } 246 el.textContent = css; 247 } 248 249 // Auto theme: listen for OS color scheme changes 250 var _autoMQ = null; 251 var _autoHandler = null; 252 253 function setupAutoListener() { 254 if (_autoMQ) return; 255 if (!window.matchMedia) return; 256 _autoMQ = window.matchMedia('(prefers-color-scheme: dark)'); 257 _autoHandler = function() { 258 var prefs = loadPrefs(); 259 if (prefs.theme === 'auto') applyTheme('auto'); 260 }; 261 _autoMQ.addEventListener('change', _autoHandler); 262 } 263 264 function teardownAutoListener() { 265 if (_autoMQ && _autoHandler) { 266 _autoMQ.removeEventListener('change', _autoHandler); 267 } 268 _autoMQ = null; 269 _autoHandler = null; 270 } 271 272 function resolveTheme(themeName) { 273 if (themeName === 'auto') { 274 return (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light'; 275 } 276 return themeName; 277 } 278 279 function applyTheme(themeName) { 280 var resolved = resolveTheme(themeName); 281 282 // Manage OS preference listener 283 if (themeName === 'auto') { 284 setupAutoListener(); 285 } else { 286 teardownAutoListener(); 287 } 288 289 document.documentElement.setAttribute('data-theme', resolved);
290 var el = document.getElementById(PREFIX + '-overrides'); 291 if (resolved === 'light') { 292 if (el) el.textContent = ''; 293 document.body.style.filter = ''; 294 document.body.style.background = ''; 295 applyAdminColors(); 296 return; 297 } 298 // Non-light themes override custom colors â disable admin color styles 299 var colorEl = document.getElementById(PREFIX + '-admin-colors'); 300 if (colorEl) colorEl.textContent = ''; 301 var filterVal = THEME_FILTERS[resolved] || ''; 302 if (!filterVal) return; 303 304 // Apply filter to <body> â affects all page content but not widget (which lives on <html>) 305 document.body.style.filter = filterVal; 306 document.body.style.background = '#fff'; 307 308 // Only re-invert images for themes that use invert() (dark, highContrast) 309 // Sepia doesn't invert so images just get the warm tint naturally 310 var css = ''; 311 if (INVERT_THEMES[resolved]) { 312 css = 313 'body[style*="filter"] img,' + 314 'body[style*="filter"] video,' + 315 'body[style*="filter"] iframe,' + 316 'body[style*="filter"] canvas,' + 317 'body[style*="filter"] svg image,' + 318 'body[style*="filter"] [style*="background-image"] {' + 319 ' filter: ' + filterVal + ' !important;' + 320 '}'; 321 } 322 323 if (!el) { 324 el = document.createElement('style'); 325 el.id = PREFIX + '-overrides'; 326 document.head.appendChild(el); 327 } 328 el.textContent = css; 329 } 330 331 function applyAll(prefs) { 332 applyTheme(prefs.theme); 333 applyFontScale(prefs.fontScale); 334 applySpacingScale(prefs.spacingScale); 335 applyFontFamily(prefs.fontFamily); 336 } 337 338 // === WIDGET UI === 339 340 var panelOpen = false; 341 var fabEl = null; 342 var panelEl = null; 343 var overlayEl = null; 344 var liveRegion = null; 345 346 var reducedMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; 347 var transition = reducedMotion ? 'none' : 'all 0.3s ease'; 348 349 function announce(msg) { 350 if (liveRegion) liveRegion.textContent = msg; 351 } 352 353 function injectWidgetStyles() { 354 var s = document.createElement('style'); 355 s.id = PREFIX + '-widget-css'; 356 s.textContent = 357 '#' + PREFIX + '-fab {' + 358 'position:fixed;bottom:80px;right:16px;width:48px;height:48px;border-radius:50%;' + 359 'background:rgba(0,0,0,0.7);color:#fff;border:none;cursor:pointer;z-index:9998;' + 360 'display:flex;align-items:center;justify-content:center;font-size:20px;' + 361 'box-shadow:0 4px 12px rgba(0,0,0,0.3);transition:' + transition + ';' + 362 'outline:none;' + 363 '}' + 364 '#' + PREFIX + '-fab:hover { transform:scale(1.1);background:rgba(0,0,0,0.85); }' + 365 '#' + PREFIX + '-fab:focus-visible { box-shadow:0 0 0 3px #60a5fa,0 4px 12px rgba(0,0,0,0.3); }' + 366 '#' + PREFIX + '-overlay {' + 367 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.4);' + 368 'z-index:9998;opacity:0;pointer-events:none;transition:' + transition + ';' + 369 '}' + 370 '#' + PREFIX + '-overlay.open { opacity:1;pointer-events:auto; }' + 371 '#' + PREFIX + '-panel {' + 372 'position:fixed;top:0;right:-440px;width:420px;height:100%;background:#fff !important;' + 373 'z-index:9999;box-shadow:-4px 0 20px rgba(0,0,0,0.15);overflow-y:auto;' + 374 'transition:' + transition + ';font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;' + 375 'color:#1f2937 !important;font-size:14px !important;' + 376 '}' + 377 '#' + PREFIX + '-panel *:not(i) { color:#1f2937 !important; }' + 378 '#' + PREFIX + '-panel h3 { color:#1f2937 !important; }' + 379 '#' + PREFIX + '-panel button { background:#fff !important; }' + 380 '#' + PREFIX + '-panel button.active { background:#eff6ff !important; color:#1d4ed8 !important; }' + 381 '#' + PREFIX + '-panel-header { background:#fff !important; border-bottom-color:#e5e7eb !important; }' + 382 '#' + PREFIX + '-panel.open { right:0; }' + 383 '@media(max-width:479px){ #' + PREFIX + '-panel { width:100%;right:-110%; } #' + PREFIX + '-panel.open { right:0; } }' + 384 '#' + PREFIX + '-panel-header {' + 385 'display:flex;align-items:center;justify-content:space-between;padding:16px 20px;' + 386 'border-bottom:1px solid #e5e7eb;' + 387 '}' + 388 '#' + PREFIX + '-panel-header h3 { margin:0;font-size:16px;font-weight:600; }' + 389 '#' + PREFIX + '-close {' + 390 'background:none !important;border:none;font-size:22px;
390cursor:pointer;color:#6b7280 !important;padding:4px 8px;border-radius:4px;' + 391 '}' + 392 '#' + PREFIX + '-close:hover { background:#f3f4f6;color:#1f2937; }' + 393 '#' + PREFIX + '-close:focus-visible { box-shadow:0 0 0 2px #60a5fa; outline:none; }' + 394 '.' + PREFIX + '-section { padding:16px 20px;border-bottom:1px solid #f3f4f6; }' + 395 '.' + PREFIX + '-section { background:#fff !important; border-bottom-color:#f3f4f6 !important; }' + 396 '.' + PREFIX + '-section-label { font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.05em;color:#6b7280 !important;margin-bottom:10px; }' + 397 '.' + PREFIX + '-row { display:flex;gap:8px;flex-wrap:wrap; }' + 398 '.' + PREFIX + '-btn {' + 399 'display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;' + 400 'padding:10px 12px;border:2px solid #e5e7eb;border-radius:8px;background:#fff;cursor:pointer;' + 401 'transition:' + transition + ';min-width:60px;font-size:12px;color:#374151;outline:none;' + 402 '}' + 403 '.' + PREFIX + '-btn:hover { border-color:#9ca3af;background:#f9fafb; }' + 404 '.' + PREFIX + '-btn:focus-visible { box-shadow:0 0 0 2px #60a5fa; }' + 405 '.' + PREFIX + '-btn.active { border-color:#2563eb;background:#eff6ff;color:#1d4ed8; }' + 406 '.' + PREFIX + '-btn i { font-size:18px; }' + 407 '.' + PREFIX + '-density-btn {' + 408 'flex:1;padding:8px 4px;border:2px solid #e5e7eb;border-radius:8px;background:#fff;cursor:pointer;' + 409 'font-size:12px;font-weight:500;text-align:center;color:#374151;transition:' + transition + ';outline:none;' + 410 '}' + 411 '.' + PREFIX + '-density-btn:hover { border-color:#9ca3af;background:#f9fafb; }' + 412 '.' + PREFIX + '-density-btn:focus-visible { box-shadow:0 0 0 2px #60a5fa; }' + 413 '.' + PREFIX + '-density-btn.active { border-color:#2563eb;background:#eff6ff;color:#1d4ed8; }' + 414 '#' + PREFIX + '-reset {' + 415 'display:block;width:calc(100% - 40px);margin:16px 20px;padding:10px;border:1px solid #e5e7eb;' + 416 'border-radius:8px;background:#fff !important;cursor:pointer;font-size:13px;color:#6b7280 !important;text-align:center;' + 417 'transition:' + transition + ';outline:none;' + 418 '}' + 419 '#' + PREFIX + '-reset:hover { background:#fef2f2;color:#dc2626;border-color:#fca5a5; }' + 420 '#' + PREFIX + '-reset:focus-visible { box-shadow:0 0 0 2px #60a5fa; }' + 421 '#' + PREFIX + '-live { position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0); }' + 422 '@media print { #' + PREFIX + '-fab, #' + PREFIX + '-panel, #' + PREFIX + '-overlay { display:none !important; } }'; 423 document.head.appendChild(s); 424 } 425 426 function createFAB() { 427 fabEl = document.createElement('button'); 428 fabEl.id = PREFIX + '-fab'; 429 fabEl.setAttribute('aria-label', 'Reading preferences'); 430 fabEl.setAttribute('title', 'Reading preferences'); 431 fabEl.innerHTML = '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="6" x2="14" y2="6"/><line x1="18" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="8" y2="12"/><line x1="12" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="16" y2="18"/><circle cx="16" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/></svg>'; 432 fabEl.addEventListener('click', togglePanel); 433 fabEl.addEventListener('keydown', function(e) { 434 if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); togglePanel(); } 435 }); 436 document.documentElement.appendChild(fabEl); 437 } 438 439 function createOverlay() { 440 overlayEl = document.createElement('div'); 441 overlayEl.id = PREFIX + '-overlay'; 442 overlayEl.addEventListener('click', closePanel); 443 document.documentElement.appendChild(overlayEl); 444 } 445 446 function createPanel() { 447 var prefs = loadPrefs(); 448 449 panelEl = document.createElement('div'); 450 panelEl.id = PREFIX + '-panel'; 451 panelEl.setAttribute('role', 'dialog'); 452 panelEl.setAttribute('aria-modal', 'true'); 453 panelEl.setAttribute('aria-label', 'Reading preferences'); 454 455 var html = ''; 456 457 // Header 458 html += '<div id="' + PREFIX + '-panel-header">'; 459 html += '<h3>Reading Preferences</h3>'; 460 html += '<button id="' + PREFIX + '-close" aria-label="Close preferences">×</button>'; 461 html += '</div>'; 462 463 // Theme section 464 html += '<div class="' + PREFIX + '-section">'; 465 html += '<div class="' + PREFIX + '-section-label">Theme</div>'; 466 html += '<div class="' + PREFIX + '-row">'; 467 var themeKeys = ['auto', 'light', 'dark', 'sepia', 'highContrast']; 468 for (var i = 0; i < themeKeys.length; i++) { 469 var tk = themeKeys[i]; 470 var th = THEMES[tk]; 471 var isActive = prefs.theme === tk ? ' active' : ''; 472 html += '<button class="' + PREFIX + '-btn' + isActive + '" data-action="theme" data-value="' + tk + '" aria-label="' + th.label + ' theme">'; 473 if (th.icon) html += '<i class="fa-solid ' + th.icon + '"></i>'; 474 html += '<span>' + th.label + '</span>'; 475 html += '</button>'; 476 } 477 html += '</div></div>'; 478 479 // Font size section 480 html += '<div class="' + PREFIX + '-section">'; 481 html += '<div class="' + PREFIX + '-section-label">Font Size</div>'; 482 html += '<div class="' + PREFIX + '-row">'; 483 for (var j = 0; j < FONT_STEPS.length; j++) { 484 var fs = FONT_STEPS[j];
485 var fsActive = prefs.fontScale === fs.scale ? ' active' : ''; 486 html += '<button class="' + PREFIX + '-btn' + fsActive + '" data-action="font" data-value="' + fs.scale + '" aria-label="Font size ' + fs.key + '" style="font-size:' + fs.size + '">'; 487 html += '<span style="font-size:' + fs.size + ';font-weight:600">' + fs.label + '</span>'; 488 html += '<span style="font-size:10px">' + fs.key.charAt(0).toUpperCase() + fs.key.slice(1) + '</span>'; 489 html += '</button>'; 490 } 491 html += '</div></div>'; 492 493 // Font Family and Spacing removed from visitor widget â admin-only settings 494 495 // Reset 496 html += '<button id="' + PREFIX + '-reset" aria-label="Reset to default settings">Reset to Defaults</button>'; 497 498 panelEl.innerHTML = html; 499 document.documentElement.appendChild(panelEl); 500 501 // Live region for screen readers 502 liveRegion = document.createElement('div'); 503 liveRegion.id = PREFIX + '-live'; 504 liveRegion.setAttribute('aria-live', 'polite'); 505 liveRegion.setAttribute('role', 'status'); 506 document.documentElement.appendChild(liveRegion); 507 508 // Event listeners 509 panelEl.querySelector('#' + PREFIX + '-close').addEventListener('click', closePanel); 510 511 panelEl.addEventListener('click', function(e) { 512 var btn = e.target.closest('[data-action]'); 513 if (!btn) return; 514 var action = btn.getAttribute('data-action'); 515 var value = btn.getAttribute('data-value'); 516 var prefs = loadPrefs(); 517 518 if (action === 'theme') { 519 prefs.theme = value; 520 applyTheme(value); 521 updateActive(panelEl, 'theme', value); 522 announce(THEMES[value].label + ' theme applied'); 523 } else if (action === 'font') { 524 var fv = parseFloat(value); 525 prefs.fontScale = fv; 526 applyFontScale(fv); 527 updateActive(panelEl, 'font', value); 528 var stepName = FONT_STEPS.filter(function(s){ return s.scale === fv; })[0]; 529 announce('Font size: ' + (stepName ? stepName.key : value)); 530 } else if (action === 'fontFamily') { 531 prefs.fontFamily = value; 532 applyFontFamily(value); 533 updateActive(panelEl, 'fontFamily', value); 534 var ffEntry = FONT_FAMILIES[value]; 535 announce('Font: ' + (ffEntry ? ffEntry.label : value)); 536 } else if (action === 'spacing') { 537 var sv = parseFloat(value); 538 prefs.spacingScale = sv; 539 applySpacingScale(sv); 540 updateActive(panelEl, 'spacing', value); 541 var dName = DENSITY_STEPS.filter(function(s){ return s.scale === sv; })[0]; 542 announce('Spacing: ' + (dName ? dName.key : value)); 543 } 544 savePrefs(prefs); 545 }); 546 547 panelEl.querySelector('#' + PREFIX + '-reset').addEventListener('click', function() { 548 clearPrefs(); 549 var fresh = { theme: 'light', fontScale: 1, spacingScale: 1, fontFamily: 'system' }; 550 savePrefs(fresh); 551 applyAll(fresh); 552 updateActive(panelEl, 'theme', fresh.theme); 553 updateActive(panelEl, 'font', String(fresh.fontScale)); 554 updateActive(panelEl, 'fontFamily', fresh.fontFamily); 555 updateActive(panelEl, 'spacing', String(fresh.spacingScale)); 556 announce('Preferences reset to defaults'); 557 }); 558 559 // Keyboard: Escape to close, trap focus 560 panelEl.addEventListener('keydown', function(e) { 561 if (e.key === 'Escape') { closePanel(); return; } 562 if (e.key === 'Tab') { 563 var focusable = panelEl.querySelectorAll('button, [tabindex]'); 564 if (focusable.length === 0) return; 565 var first = focusable[0]; 566 var last = focusable[focusable.length - 1]; 567 if (e.shiftKey && document.activeElement === first) { 568 e.preventDefault(); last.focus(); 569 } else if (!e.shiftKey && document.activeElement === last) { 570 e.preventDefault(); first.focus(); 571 } 572 } 573 }); 574 } 575 576 function updateActive(container, action, value) { 577 var btns = container.querySelectorAll('[data-action="' + action + '"]'); 578 for (var i = 0; i < btns.length; i++) { 579 if (btns[i].getAttribute('data-value') === value) { 580 btns[i].classList.add('active'); 581 } else { 582 btns[i].classList.remove('active'); 583 } 584 } 585 } 586 587 function togglePanel() { 588 if (panelOpen) closePanel();
588 else openPanel(); 589 } 590 591 function openPanel() { 592 panelOpen = true; 593 panelEl.classList.add('open'); 594 overlayEl.classList.add('open'); 595 // Focus the close button 596 var closeBtn = panelEl.querySelector('#' + PREFIX + '-close'); 597 if (closeBtn) closeBtn.focus(); 598 } 599 600 function closePanel() { 601 panelOpen = false; 602 panelEl.classList.remove('open'); 603 overlayEl.classList.remove('open'); 604 if (fabEl) fabEl.focus(); 605 } 606 607 // === INIT === 608 609 function init() { 610 // Apply prefs early (theme already set by GetUserStyles(), but ensure full application) 611 var prefs = loadPrefs(); 612 applyAll(prefs); 613 614 function buildUI() { 615 injectWidgetStyles(); 616 createOverlay(); 617 createFAB(); 618 createPanel(); 619 } 620 621 if (document.readyState === 'loading') { 622 document.addEventListener('DOMContentLoaded', buildUI); 623 } else { 624 buildUI(); 625 } 626 } 627 628 init(); 629})();
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.