PageSourceSearch

https://sol.vin/js/palette_engine.js

js sol.vin collected 2026-10-09 21:58:22 UTC 25,000 bytes, 653 lines download raw bytes

1/**
2 * PaletteEngine - Dynamic Color Palette Swapping, Cross-Subdomain Cookie Engine,
3 * and Real-Time Tab Synchronization for SOL.VIN (Deterministic Indexing)
4 */
5(function(window) {
6  'use strict';
7
8  var PaletteEngine = {
9    palettes: [],
10    currentPalette: null,
11    currentIndex: 0,
12    STORAGE_KEY: 'solvin_palette_id',
13    CHANNEL_NAME: 'solvin_palette_channel',
14    channel: null,
15
16    /**
17     * Resolves multi-label root domain for setting shared domain cookies across subdomains.
18     * Only returns domains with dots (e.g. sol.vin, dev.sol.vin, lvh.me) because modern
19     * browsers reject domain= attributes on single-label domains like localhost.
20     */
21    getRootDomain: function() {
22      var host = window.location.hostname;
23      if (!host) return null;
24      var isIp = /^(\d{1,3}\.){3}\d{1,3}$/.test(host) || host.includes(':');
25      if (isIp || host === 'localhost' || host.endsWith('.localhost')) return null;
26      var parts = host.split('.');
27      if (parts.length < 2) return null;
28      return parts.slice(-2).join('.');
29    },
30
31    /**
32     * Reads a cookie value by name from document.cookie.
33     */
34    getCookie: function(name) {
35      var match = document.cookie.match(new RegExp('(?:^|; )' + name.replace(/([\.$?*|{}\(\)\[\]\\\/\+^])/g, '\\$1') + '=([^;]*)'));
36      return match ? decodeURIComponent(match[1]) : null;
37    },
38
39    /**
40     * Writes a cookie. Scopes to root domain if multi-label domain (e.g. domain=sol.vin).
41     */
42    setCookie: function(name, value, days) {
43      var rootDomain = this.getRootDomain();
44      var expires = days ? '; max-age=' + (days * 86400) : '';
45      var secure = window.location.protocol === 'https:' ? '; Secure' : '';
46      var cookieStr = name + '=' + encodeURIComponent(value) + expires + '; path=/; SameSite=Lax' + secure;
47      if (rootDomain) {
48        cookieStr += '; domain=' + rootDomain;
49      }
50      document.cookie = cookieStr;
51    },
52
53    /**
54     * Retrieves saved palette ID strictly from cookies.
55     */
56    getSavedPaletteId: function() {
57      return this.getCookie(this.STORAGE_KEY);
58    },
59
60    /**
61     * Resolves variable color keys against defined color dictionary.
62     */
63    getResolvedColors: function(palette) {
64      if (!palette) return {};
65      if (!palette.variables && palette.colors) {
66        return palette.colors;
67      }
68      var colorDefs = palette.palette_colors || palette.colors || {};
69      var vars = palette.variables || palette.colors || {};
70      var resolved = {};
71      for (var key in vars) {
72        if (Object.prototype.hasOwnProperty.call(vars, key)) {
73          var val = vars[key];
74          if (typeof val === 'object' && val !== null) {
75            val = val.value;
76          }
77          if (typeof val === 'string' && colorDefs[val]) {
78            resolved[key] = (typeof colorDefs[val] === 'object' && colorDefs[val] !== null) ? colorDefs[val].value : colorDefs[val];
79          } else {
80            resolved[key] = val;
81          }
82        }
83      }
84      return resolved;
85    },
86
87    /**
88     * Applies CSS variable overrides to documentElement and updates active dataset attributes.
89     */
90    applyPalette: function(palette) {
91      if (!palette) return;
92      var colors = this.getResolvedColors(palette);
93      if (!colors) return;
94      var root = document.documentElement;
95      for (var key in colors) {
96        if (Object.prototype.hasOwnProperty.call(colors, key)) {
97          var val = colors[key];
98          var cssVarName = '--' + key.replace(/_/g, '-');
99          root.style.setProperty(cssVarName, val);
100        }
101      }
102      if (!colors.cube) {
103        var textVal = colors.cube_stroke_color || colors.text_color || '#1c1c1e';
104        root.style.setProperty('--cube', textVal);
105      }
106      if (!colors.cube_hover) {
107        var hoverVal = colors.border_active || colors.accent_color || colors.text_color || '#1c1c1e';
108        root.style.setProperty('--cube-hover', hoverVal);
109      }
110      root.style.setProperty('--cube-stroke-color', colors.cube || colors.cube_stroke_color || colors.text_color || '#1c1c1e');
111      root.setAttribute('data-palette', palette.id || '');
112      this.updateButtonLabel(palette);
113      this.updateDuotoneFilter(palette);
114      this.refreshChartColors();
115      try {
116        if (typeof document !== 'undefined' && document.dispatchEvent) {
117          document.dispatchEvent(new CustomEvent('palettechange', { detail: { palette: palette } }));
118        }
119      } catch(e) {}
120    },
121
122    /**
123     * Refreshes all active Chart.js chart instances on the page if Chart.js is loaded.
124     */
125    refreshChartColors: function() {
126      if (typeof window !== 'undefined' && window.Chart && window.Chart.instances) {
127        var instances = window.Chart.instances;
128        for (var id in instances) {
129          if (Object.prototype.hasOwnProperty.call(instances, id)) {
130            var chart = instances[id];
131            if (chart && typeof chart.update === 'function') {
132              chart.update();
133            }
134          }
135        }
136      }
137    },
138
139    /**
140     * Parses CSS hex/rgb color string into normalized RGB values [0..1].
141     */
142    parseColorToRgb: function(colorStr) {
143      if (colorStr === null || colorStr === undefined) return { r: 0, g: 0, b: 0 };
144      if (typeof colorStr !== 'string') {
145        if (typeof colorStr === 'object' && colorStr !== null && colorStr.value) {
146          colorStr = String(colorStr.value);
147        } else {
148          colorStr = String(colorStr);
149        }
150      }
151      colorStr = colorStr.trim();
152      if (!colorStr) return { r: 0, g: 0, b: 0 };
153
154      var cleanHex = colorStr.startsWith('#') ? colorStr.substring(1) : colorStr;
155      if (/^[0-9a-fA-F]{3}$/.test(cleanHex) || /^[0-9a-fA-F]{6}$/.test(cleanHex)) {
156        if (cleanHex.length === 3) {
157          cleanHex = cleanHex[0] + cleanHex[0] + cleanHex[1] + cleanHex[1] + cleanHex[2] + cleanHex[2];
158        }
159        var num = parseInt(cleanHex, 16);
160        if (!isNaN(num)) {
161          return {
162            r: ((num >> 16) & 255) / 255,
163            g: ((num >> 8) & 255) / 255,
164            b: (num & 255) / 255
165          };
166        }
167      }
168      var match = colorStr.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
169      if (match) {
170        return {
171          r: parseInt(match[1], 10) / 255,
172          g: parseInt(match[2], 10) / 255,
173          b: parseInt(match[3], 10) / 255
174        };
175      }
176      return { r: 0, g: 0, b: 0 };
177    },
178
179    /**
180     * Calculates feColorMatrix 5x4 matrix values string for interpolating between
181     * duotone color palette mapping (progress t=0.0) and full-color original image (progress t=1.0).
182     */
183    getDuotoneMatrixValues: function(palette, t) {
184      t = Math.max(0, Math.min(1, t));
185      var startVal = '#1c1c1e';
186      var endVal = '#faf6ee';
187
188      var colors = this.getResolvedColors(palette);
189      if (colors && Object.keys(colors).length > 0) {
190        if (colors.filter_start_color) {
191          startVal = colors.filter_start_color;
192        } else if (colors.text_color) {
193          startVal = colors.text_color;
194        }
195        if (colors.filter_end_color) {
196          endVal = colors.filter_end_color;
197        } else if (colors.bg_color) {
198          endVal = colors.bg_color;
199        }
200      } else if (typeof window !== 'undefined' && window.getComputedStyle) {
201        var rootStyles = getComputedStyle(document.documentElement);
202        if (rootStyles) {
203          var getVar = function(prop) {
204            var raw = rootStyles.getPropertyValue(prop);
205            return (typeof raw === 'string' && raw) ? raw.trim() : '';
206          };
207          startVal = getVar('--filter-start-color') || getVar('--text-color') || startVal;
208          endVal = getVar('--filter-end-color') || getVar('--bg-color') || endVal;
209        }
210      }
211
212      var bg = this.parseColorToRgb(endVal);
213      var txt = this.parseColorToRgb(startVal);
214
215      var dr = bg.r - txt.r;
216      var dg = bg.g - txt.g;
217      var db = bg.b - txt.b;
218
219      var invT = 1 - t;
220
221      var rR = invT * dr * 0.2126 + t;
222      var rG = invT * dr * 0.7152;
223      var rB = invT * dr * 0.0722;
224      var rC = invT * txt.r;
225
226      var gR = invT * dg * 0.2126;
227      var gG = invT * dg * 0.7152 + t;
228      var gB = invT * dg * 0.0722;
229      var gC = invT * txt.g;
230
231      var bR = invT * db * 0.2126;
232      var bG = invT * db * 0.7152;
233      var bB = invT * db * 0.0722 + t;
234      var bC = invT * txt.b;
235
236      return [
237        rR.toFixed(4), rG.toFixed(4), rB.toFixed(4), 0, rC.toFixed(4),
238        gR.toFixed(4), gG.toFixed(4), gB.toFixed(4), 0, gC.toFixed(4),
239        bR.toFixed(4), bG.toFixed(4), bB.toFixed(4), 0, bC.toFixed(4),
240        0, 0, 0, 1, 0
241      ].join(' ');
242    },
243
244    /**
245     * Updates SVG feColorMatrix values with current palette text and bg colors.
246     */
247    updateDuotoneFilter: function(palette) {
248      var self = this;
249      this._activePalette = palette;
250      var globalMatrix = document.getElementById('palette-duotone-matrix');
251      if (globalMatrix) {
252        globalMatrix.setAttribute('values', this.getDuotoneMatrixValues(palette, 0));
253      }
254
255      var svgContainer = document.getElementById('duotone-svg-container');
256      if (!svgContainer) return;
257
258      var activeFilters = svgContainer.querySelectorAll('feColorMatrix[data-duotone-matrix]');
259      for (var f = 0; f < activeFilters.length; f++) {
260        var mat = activeFilters[f];
261        var img = mat._boundImg;
262        var currentT = (img && typeof img._duotoneProgress === 'number') ? img._duotoneProgress : 0;
263        mat.setAttribute('values', self.getDuotoneMatrixValues(palette, currentT));
264      }
265    },
266
267    /**
268     * Updates the UI button text label if element is present in DOM.
269     */
270    updateButtonLabel: function(palette) {
271      var label = document.getElementById('palette-name-label');
272      if (label && palette) {
273        label.textContent = palette.name || palette.id;
274      }
275      var preview = document.getElementById('palette-color-preview');
276      if (preview && palette) {
277        var colors = this.getResolvedColors(palette);
278        var secColor = colors.accent_secondary || colors.accent_color || colors.text_color;
279        if (secColor) preview.style.color = secColor;
280      }
281    },
282
283    openPaletteModal: function() {
284      var modalOverlay = document.getElementById('palette-picker-modal-overlay');
285      var modalGrid = document.getElementById('palette-picker-grid');
286      if (!modalOverlay || !modalGrid) return;
287
288      this.renderPaletteGrid();
289      modalOverlay.style.cssText = 'display: flex !important; z-index: 999999;';
290      document.body.style.overflow = 'hidden';
291    },
292
293    closePaletteModal: function() {
294      var modalOverlay = document.getElementById('palette-picker-modal-overlay');
295      if (!modalOverlay) return;
296      modalOverlay.style.cssText = 'display: none !important;';
297      document.body.style.overflow = '';
298    },
299
300    renderPaletteGrid: function() {
301      var modalGrid = document.getElementById('palette-picker-grid');
302      if (!modalGrid || !this.palettes) return;
303
304      var self = this;
305      modalGrid.innerHTML = '';
306
307      for (var i = 0; i < this.palettes.length; i++) {
308        (function(idx) {
309          var pal = self.palettes[idx];
310          var btn = document.createElement('button');
311          btn.type = 'button';
312          btn.className = 'export-btn palette-grid-item-btn';
313
314          var colors = self.getResolvedColors(pal);
315          var bg = colors.bg_color || '#faf6ee';
316          var txt = colors.text_color || '#1c1c1e';
317          var border = colors.border_color || '#1c1c1e';
318          var shadow = colors.shadow_color || border;
319          var circleColor = colors.accent_secondary || colors.accent_color || txt;
320
321          btn.style.cssText = 'background: ' + bg + ' !important; color: ' + txt + ' !important; border: 2px solid ' + border + ' !important; box-shadow: 2px 2px 0px ' + shadow + ' !important; padding: 0.6rem 0.8rem; font-size: 0.85rem; font-weight: bold; cursor: pointer; text-align: left; display: flex; align-items: center; justify-content: space-between; font-family: monospace; border-radius: 3px;';
322
323          if (idx === self.currentIndex) {
324            btn.style.outline = '2px solid ' + txt;
325          }
326
327          btn.innerHTML = '<span style="display: flex; align-items: center; gap: 0.4rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">' +
328                          '<span style="color: ' + circleColor + '; font-size: 0.9rem;">●</span> ' +
329                          '<span>' + (pal.name || pal.id) + '</span>' +
330                          '</span>' +
331                          '<span style="font-size: 0.75rem; opacity: 0.75;">[ #' + (idx + 1) + ' ]</span>';
332
333          btn.addEventListener('click', function(e) {
334            e.preventDefault();
335            self.selectIndex(idx);
336            self.closePaletteModal();
337          });
338
339          modalGrid.appendChild(btn);
340        })(i);
341      }
342    },
343
344    findPaletteIndexById: function(id) {
345      if (!id || !this.palettes) return -1;
346      for (var i = 0; i < this.palettes.length; i++) {
347        if (this.palettes[i].id === id) return i;
348      }
349      return -1;
350    },
351
352    selectIndex: function(idx) {
353      if (!this.palettes || this.palettes.length === 0) return;
354      var safeIdx = (idx % this.palettes.length + this.palettes.length) % this.palettes.length;
355      this.currentIndex = safeIdx;
356      this.currentPalette = this.palettes[safeIdx];
357      this.applyPalette(this.currentPalette);
358
359      if (this.currentPalette && this.currentPalette.id) {
360        this.setCookie(this.STORAGE_KEY, this.currentPalette.id, 365);
361        if (this.channel) {
362          try { this.channel.postMessage({ id: this.currentPalette.id, index: safeIdx }); } catch(e) {}
363        }
364      }
365    },
366
367    nextPalette: function() {
368      if (!this.palettes || this.palettes.length === 0) return;
369      this.selectIndex(this.currentIndex + 1);
370    },
371
372    prevPalette: function() {
373      if (!this.palettes || this.palettes.length === 0) return;
374      this.selectIndex(this.currentIndex - 1);
375    },
376
377    resetDefaultPalette: function() {
378      this.selectIndex(0);
379    },
380
381    randomPalette: function() {
382      if (!this.palettes || this.palettes.length === 0) return;
383      if (this.palettes.length === 1) {
384        this.selectIndex(0);
385        return;
386      }
387      var randomIdx = this.currentIndex;
388      do {
389        randomIdx = Math.floor(Math.random() * this.palettes.length);
390      } while (randomIdx === this.currentIndex);
391      this.selectIndex(randomIdx);
392    },
393
394    syncFromStorage: function() {
395      var savedId = this.getSavedPaletteId();
396      if (savedId) {
397        var idx = this.findPaletteIndexById(savedId);
398        if (idx >= 0 && idx !== this.currentIndex) {
399          this.selectIndex(idx);
400        }
401      }
402    },
403
404    initHead: function(palettes) {
405      this.palettes = palettes || window.SITE_PALETTES || [];
406      var savedId = this.getSavedPaletteId();
407      var idx = this.findPaletteIndexById(savedId);
408      if (idx < 0) idx = 0;
409      this.selectIndex(idx);
410    },
411
412    initDuotoneImages: function(scope) {
413      scope = scope || document;
414      var self = this;
415
416      // 1. Unwrap any legacy wrapper spans (.photo-filter-container) and remove legacy overlay elements
417      var containers = scope.querySelectorAll('.photo-filter-container');
418      for (var c = 0; c < containers.length; c++) {
419        var cont = containers[c];
420        var realImg = cont.querySelector('img:not(.photo-filter-overlay)');
421        if (realImg && cont.parentNode) {
422          cont.parentNode.insertBefore(realImg, cont);
423        }
424        cont.remove();
425      }
426      var legacyOverlays = scope.querySelectorAll('.photo-filter-overlay, .duotone-overlay-img');
427      for (var k = 0; k < legacyOverlays.length; k++) {
428        legacyOverlays[k].remove();
429      }
430
431      var svgContainer = document.getElementById('duotone-svg-container');
432      if (!svgContainer) {
433        svgContainer = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
434        svgContainer.id = 'duotone-svg-container';
435        svgContainer.setAttribute('aria-hidden', 'true');
436        svgContainer.style.cssText = 'position: absolute; width: 0; height: 0; overflow: hidden; opacity: 0; pointer-events: none;';
437        document.body.appendChild(svgContainer);
438      }
439
440      if (!self._duotoneCounter) self._duotoneCounter = 0;
441
442      // 2. Query all img tags
443      var imgs = scope.querySelectorAll('img');
444      for (var i = 0; i < imgs.length; i++) {
445        var img = imgs[i];
446
447        // 3. Check for explicit filter opt-out attribute filter="0" or data-filter="0"
448        if (img.getAttribute('filter') === '0' || img.getAttribute('data-filter') === '0') {
449          img.classList.remove('duotone-filtered');
450          img.style.filter = 'none';
451          img.style.webkitFilter = 'none';
452          continue;
453        }
454
455        // 4. Check for opt-out container classes / attributes or Image Viewer Modal
456        if (img.id === 'image-viewer-img' || 
457            img.closest('#gui-image-modal, .image-viewer-window, .image-viewer-body, [data-monochrome="false"], [data-palette-filter="false"], .no-monochrome, .no-palette-filter, .is-pdf, .pdf-resume-container, .is-export')) {
458          img.classList.remove('duotone-filtered');
459          img.style.filter = 'none';
460          img.style.webkitFilter = 'none';
461          continue;
462        }
463
464        // 5. Skip icons, SVGs, header logo
465        var src = (img.currentSrc || img.src || '').toLowerCase();
466        if (img.closest('.terminal-logo, .terminal-header, #header-cube-app, .render-svg-icon, .skill-icon-svg, .article-link-brand-icon, .rss-link-btn, .accolade-link-btn') ||
467            img.classList.contains('article-link-brand-icon') ||
468            (src.indexOf('.svg') !== -1 && !img.closest('[data-palette-filter="true"]')) ||
469            (src.indexOf('/icons/') !== -1 && !img.closest('[data-palette-filter="true"]'))) {
470          continue;
471        }
472
473        // 6. Assign unique SVG filter for each img
474        if (!img._duotoneFilterId) {
475          self._duotoneCounter++;
476          img._duotoneFilterId = 'duotone-filter-img-' + self._duotoneCounter;
477
478          var filterElem = document.createElementNS('http://www.w3.org/2000/svg', 'filter');
479          filterElem.setAttribute('id', img._duotoneFilterId);
480          filterElem.setAttribute('color-interpolation-filters', 'sRGB');
481
482          var matrixElem = document.createElementNS('http://www.w3.org/2000/svg', 'feColorMatrix');
483          matrixElem.setAttribute('type', 'matrix');
484          matrixElem.setAttribute('data-duotone-matrix', 'true');
485          matrixElem._boundImg = img;
486          matrixElem.setAttribute('values', self.getDuotoneMatrixValues(self._activePalette || self.currentPalette, 0));
487
488          filterElem.appendChild(matrixElem);
489          svgContainer.appendChild(filterElem);
490
491          img._duotoneMatrixElem = matrixElem;
492          img._duotoneProgress = 0; // 0 = duotone, 1 = full color
493        }
494
495        // Apply SVG filter directly to img element
496        img.classList.add('duotone-filtered');
497        img.style.filter = 'url(#' + img._duotoneFilterId + ')';
498        img.style.webkitFilter = 'url(#' + img._duotoneFilterId + ')';
499
500        // 7. Bind 2-second hover transition loop (JS requestAnimationFrame)
501        if (!img._duotoneEventsBound) {
502          img._duotoneEventsBound = true;
503
504          var bindAnimation = function(targetImg) {
505            var startAnim = function(targetProgress) {
506              if (targetImg._duotoneAnimId) {
507                cancelAnimationFrame(targetImg._duotoneAnimId);
508                targetImg._duotoneAnimId = null;
509              }
510
511              var startProgress = typeof targetImg._duotoneProgress === 'number' ? targetImg._duotoneProgress : 0;
512              var duration = 2000; // 2 seconds
513              var startTime = null;
514
515              function step(timestamp) {
516                if (!startTime) startTime = timestamp;
517                var elapsed = timestamp - startTime;
518                var p = Math.min(1, elapsed / duration);
519                
520                // Smooth cubic ease-in-out curve
521                var easedP = p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
522
523                targetImg._duotoneProgress = startProgress + (targetProgress - startProgress) * easedP;
524
525                if (targetImg._duotoneMatrixElem) {
526                  targetImg._duotoneMatrixElem.setAttribute('values', self.getDuotoneMatrixValues(self._activePalette || self.currentPalette, targetImg._duotoneProgress));
527                }
528
529                if (p < 1) {
530                  targetImg._duotoneAnimId = requestAnimationFrame(step);
531                } else {
532                  targetImg._duotoneProgress = targetProgress;
533                  targetImg._duotoneAnimId = null;
534                }
535              }
536
537              targetImg._duotoneAnimId = requestAnimationFrame(step);
538            };
539
540            // Determine hover trigger target: parent gallery wrapper if inside a photo gallery, or the img itself
541            var hoverTarget = targetImg.closest('.photo-carousel-container, .photo-carousel-wrapper, .game-carousel-wrapper, .gallery-main-frame, .carousel-main-frame') || targetImg;
542
543            hoverTarget.addEventListener('mouseenter', function() {
544              startAnim(1.0); // Smooth 2s fade to full color
545            });
546
547            hoverTarget.addEventListener('mouseleave', function() {
548              startAnim(0.0); // Smooth 2s fade back to duotone
549            });
550          };
551
552          bindAnimation(img);
553        }
554      }
555    },
556
557    initDom: function() {
558      if (this._domInitialized) return;
559      this._domInitialized = true;
560      var self = this;
561
562      if (!this.currentPalette && (!this.palettes || this.palettes.length === 0)) {
563        if (typeof window !== 'undefined' && window.SITE_PALETTES && window.SITE_PALETTES.length > 0) {
564          this.initHead(window.SITE_PALETTES);
565        }
566      }
567
568      if (this.currentPalette) {
569        this.updateButtonLabel(this.currentPalette);
570        this.updateDuotoneFilter(this.currentPalette);
571      } else {
572        this.updateDuotoneFilter(null);
573      }
574
575      this.initDuotoneImages();
576
577      if ('MutationObserver' in window) {
578        var domObserver = new MutationObserver(function() {
579          self.initDuotoneImages();
580        });
581        domObserver.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['src'] });
582      }
583
584      try {
585        if ('BroadcastChannel' in window && !this.channel) {
586          this.channel = new BroadcastChannel(this.CHANNEL_NAME);
587          this.channel.onmessage = function(e) {
588            if (e && e.data) {
589              if (typeof e.data.index === 'number' && e.data.index >= 0 && e.data.index < self.palettes.length) {
590                if (e.data.index !== self.currentIndex) {
591                  self.selectIndex(e.data.index);
592                }
593              } else if (e.data.id) {
594                var idx = self.findPaletteIndexById(e.data.id);
595                if (idx >= 0 && idx !== self.currentIndex) {
596                  self.selectIndex(idx);
597                }
598              }
599            }
600          };
601        }
602      } catch(e) {}
603
604      window.addEventListener('visibilitychange', function() {
605        if (!document.hidden) self.syncFromStorage();
606      });
607      window.addEventListener('focus', function() {
608        self.syncFromStorage();
609      });
610
611      var closeBtn = document.getElementById('palette-picker-modal-close');
612      var modalOverlay = document.getElementById('palette-picker-modal-overlay');
613      var randomBtn = document.getElementById('palette-picker-random-btn');
614
615      if (randomBtn) {
616        randomBtn.addEventListener('click', function(e) {
617          e.preventDefault();
618          self.randomPalette();
619        });
620      }
621      if (closeBtn) {
622        closeBtn.addEventListener('click', function(e) {
623          e.preventDefault();
624          self.closePaletteModal();
625        });
626      }
627      if (modalOverlay) {
628        modalOverlay.addEventListener('click', function(e) {
629          if (e.target === modalOverlay) self.closePaletteModal();
630        });
631      }
632      document.addEventListener('keydown', function(e) {
633        if (e.key === 'Escape' && modalOverlay && modalOverlay.style.display !== 'none') {
634          self.closePaletteModal();
635        }
636      });
637
638      var btn = document.getElementById('palette-swap-btn');
639      if (btn && window.HoldClick) {
640        HoldClick.attach(btn, {
641          holdDuration: 2000,
642          spinnerDelay: 500,
643          onLeftClick: function() { self.nextPalette(); },
644          onRightClick: function() { self.prevPalette(); },
645          onLeftHold: function() { self.openPaletteModal(); },
646          onRightHold: function() { self.resetDefaultPalette(); }
647        });
648      }
649    }
650  };
651
652  window.PaletteEngine = PaletteEngine;
653})(window);

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.