PageSourceSearch

https://swvasun.com/sys-user-prefs.js

js swvasun.com collected 2026-09-29 05:16:04 UTC 28,524 bytes, 629 lines download raw bytes

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">&times;</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.