My agentic slop goes here. Not intended for anyone else!
1/** 2 * Arod Theme Toggle Component 3 * A theme switcher for light/dark/system modes 4 * 5 * Usage: 6 * <arod-theme></arod-theme> 7 * 8 * Note: This component manages the theme at the document level 9 * by setting a data-theme attribute on <html> or toggling a class. 10 * It also persists the preference in localStorage. 11 */ 12 13// ============================================ 14// AROD-THEME - Theme Toggle Component 15// ============================================ 16class ArodTheme extends HTMLElement { 17 constructor() { 18 super(); 19 this.attachShadow({ mode: 'open' }); 20 this.themes = ['system', 'light', 'dark', 'high-contrast', 'colorblind', 'sepia']; 21 this.currentTheme = 'system'; 22 } 23 24 connectedCallback() { 25 this.loadTheme(); 26 this.render(); 27 this.setupEventListeners(); 28 this.applyTheme(); 29 this.watchSystemTheme(); 30 } 31 32 disconnectedCallback() { 33 this.unwatchSystemTheme(); 34 } 35 36 render() { 37 this.shadowRoot.innerHTML = ` 38 <style> 39 :host { 40 display: inline-block; 41 position: relative; 42 } 43 44 .theme-toggle { 45 position: relative; 46 padding: 0.5rem; 47 background: transparent; 48 border: 1px solid var(--theme-border, #666); 49 border-radius: 4px; 50 color: var(--theme-text, #1a1a1a); 51 cursor: pointer; 52 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); 53 } 54 55 @media (prefers-color-scheme: dark) { 56 .theme-toggle { 57 --theme-border: #444; 58 --theme-text: #e0e0e0; 59 } 60 } 61 62 .theme-toggle:hover { 63 border-color: var(--theme-accent, #090c8d); 64 background: rgba(9, 12, 141, 0.05); 65 } 66 67 .theme-toggle:active { 68 transform: scale(0.95); 69 } 70 71 .theme-icon { 72 width: 20px; 73 height: 20px; 74 display: block; 75 position: relative; 76 } 77 78 /* Icon transition animations */ 79 .theme-icon svg { 80 position: absolute; 81 top: 0; 82 left: 0; 83 transition: opacity 0.3s ease, transform 0.3s ease; 84 } 85 86 .theme-icon svg.entering { 87 opacity: 1; 88 transform: rotate(0deg) scale(1); 89 } 90 91 .theme-icon svg.exiting { 92 opacity: 0; 93 transform: rotate(-90deg) scale(0.5); 94 } 95 96 /* Hover rotation effect */ 97 .theme-toggle:hover .theme-icon svg.entering { 98 transform: rotate(20deg) scale(1); 99 } 100 101 /* Tooltip */ 102 .tooltip { 103 position: absolute; 104 top: calc(100% + 8px); 105 left: 50%; 106 transform: translateX(-50%) scale(0.8); 107 background: #333; 108 color: white; 109 padding: 0.25rem 0.5rem; 110 border-radius: 4px; 111 font-size: 0.75rem; 112 white-space: nowrap; 113 pointer-events: none; 114 opacity: 0; 115 transition: opacity 0.2s ease, transform 0.2s ease; 116 z-index: 1000; 117 } 118 119 .tooltip::after { 120 content: ''; 121 position: absolute; 122 bottom: 100%; 123 left: 50%; 124 transform: translateX(-50%); 125 width: 0; 126 height: 0; 127 border-left: 4px solid transparent; 128 border-right: 4px solid transparent; 129 border-bottom: 4px solid #333; 130 } 131 132 .theme-toggle:hover .tooltip { 133 opacity: 1; 134 transform: translateX(-50%) scale(1); 135 } 136 137 /* Dark mode overrides when active */ 138 :host([data-theme="dark"]) .theme-toggle { 139 --theme-border: #444; 140 --theme-text: #e0e0e0; 141 } 142 143 :host([data-theme="light"]) .theme-toggle { 144 --theme-border: #e0e0e0; 145 --theme-text: #1a1a1a; 146 } 147 </style> 148 149 <button class="theme-toggle" aria-label="Toggle theme"> 150 <span class="theme-icon"></span> 151 <span class="tooltip"></span> 152 </button> 153 `; 154 } 155 156 setupEventListeners() { 157 const toggle = this.shadowRoot.querySelector('.theme-toggle'); 158 159 toggle.addEventListener('click', () => { 160 this.cycleTheme(); 161 }); 162 } 163 164 cycleTheme() { 165 const currentIndex = this.themes.indexOf(this.currentTheme); 166 const nextIndex = (currentIndex + 1) % this.themes.length; 167 const nextTheme = this.themes[nextIndex]; 168 169 this.setTheme(nextTheme); 170 } 171 172 loadTheme() { 173 // Load from localStorage or default to system 174 const saved = localStorage.getItem('arod-theme'); 175 this.currentTheme = saved || 'system'; 176 } 177 178 setTheme(theme) { 179 this.currentTheme = theme; 180 localStorage.setItem('arod-theme', theme); 181 this.applyTheme(); 182 this.updateUI(); 183 184 // Dispatch event for other components to react 185 this.dispatchEvent(new CustomEvent('theme-change', { 186 detail: { theme }, 187 bubbles: true, 188 composed: true 189 })); 190 } 191 192 applyTheme() { 193 const root = document.documentElement; 194 let effectiveTheme = this.currentTheme; 195 196 if (this.currentTheme === 'system') { 197 effectiveTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; 198 } 199 200 // Set data attribute on html element 201 root.setAttribute('data-theme', effectiveTheme); 202 203 // Also set on this component for styling 204 this.setAttribute('data-theme', effectiveTheme); 205 206 // Update all arod components 207 document.querySelectorAll('arod-menu, article-sidenotes').forEach(el => { 208 el.setAttribute('theme', effectiveTheme); 209 }); 210 211 this.updateUI(); 212 } 213 214 updateUI() { 215 const icon = this.shadowRoot.querySelector('.theme-icon'); 216 const toggle = this.shadowRoot.querySelector('.theme-toggle'); 217 const tooltip = this.shadowRoot.querySelector('.tooltip'); 218 219 // Clear existing icon with animation 220 const existingIcons = icon.querySelectorAll('svg'); 221 existingIcons.forEach(svg => { 222 svg.classList.remove('entering'); 223 svg.classList.add('exiting'); 224 }); 225 226 // Add new icon after a short delay for smooth transition 227 setTimeout(() => { 228 let iconSvg; 229 let ariaLabel; 230 let themeName; 231 232 switch (this.currentTheme) { 233 case 'system': 234 // Hybrid icon for system mode 235 iconSvg = `<svg class="entering" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 236 <defs> 237 <clipPath id="halfClip"> 238 <rect x="0" y="0" width="12" height="24"/> 239 </clipPath> 240 </defs> 241 <!-- Half sun (left side) --> 242 <g clip-path="url(#halfClip)"> 243 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707"></path> 244 <circle cx="12" cy="12" r="4" stroke-width="2"/> 245 </g> 246 <!-- Half moon (right side) --> 247 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 12c0-2.21 1.79-4 4-4 .35 0 .69.04 1.01.13A7 7 0 1112 19c0-.34.03-.67.08-1 2.21 0 4-1.79 4-4z" opacity="0.8"></path> 248 </svg>`; 249 ariaLabel = 'Toggle theme (System)'; 250 themeName = 'System'; 251 break; 252 253 case 'light': 254 // Sun icon for light mode 255 iconSvg = `<svg class="entering" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 256 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path> 257 </svg>`; 258 ariaLabel = 'Toggle theme (Light)'; 259 themeName = 'Light'; 260 break; 261 262 case 'dark': 263 // Moon icon for dark mode 264 iconSvg = `<svg class="entering" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 265 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path> 266 </svg>`; 267 ariaLabel = 'Toggle theme (Dark)'; 268 themeName = 'Dark'; 269 break; 270 271 case 'high-contrast': 272 // Eye icon for high contrast 273 iconSvg = `<svg class="entering" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 274 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path> 275 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path> 276 </svg>`; 277 ariaLabel = 'Toggle theme (High Contrast)'; 278 themeName = 'High Contrast'; 279 break; 280 281 case 'colorblind': 282 // Palette icon for colorblind mode 283 iconSvg = `<svg class="entering" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 284 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"></path> 285 </svg>`; 286 ariaLabel = 'Toggle theme (Colorblind)'; 287 themeName = 'Colorblind'; 288 break; 289 290 case 'sepia': 291 // Book icon for sepia/reading mode 292 iconSvg = `<svg class="entering" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 293 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path> 294 </svg>`; 295 ariaLabel = 'Toggle theme (Sepia)'; 296 themeName = 'Sepia'; 297 break; 298 299 default: 300 // Default to system icon 301 iconSvg = `<svg class="entering" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 302 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path> 303 </svg>`; 304 ariaLabel = 'Toggle theme'; 305 themeName = 'System'; 306 } 307 308 icon.innerHTML = iconSvg; 309 toggle.setAttribute('aria-label', ariaLabel); 310 tooltip.textContent = themeName; 311 }, 150); 312 } 313 314 getEffectiveTheme() { 315 if (this.currentTheme === 'system') { 316 return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; 317 } 318 return this.currentTheme; 319 } 320 321 watchSystemTheme() { 322 this.mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); 323 this.systemThemeHandler = () => { 324 if (this.currentTheme === 'system') { 325 this.applyTheme(); 326 } 327 }; 328 this.mediaQuery.addEventListener('change', this.systemThemeHandler); 329 } 330 331 unwatchSystemTheme() { 332 if (this.mediaQuery && this.systemThemeHandler) { 333 this.mediaQuery.removeEventListener('change', this.systemThemeHandler); 334 } 335 } 336} 337 338// Register component 339customElements.define('arod-theme', ArodTheme);