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);