My agentic slop goes here. Not intended for anyone else!
1/**
2 * Sidenotes Web Components
3 *
4 * Usage:
5 * <article-sidenotes>
6 * <p>Text with reference<sidenote-ref for="note1"></sidenote-ref></p>
7 * <side-note id="note1">Note content</side-note>
8 * </article-sidenotes>
9 */
10
11// ============================================
12// ARTICLE-SIDENOTES - Main Container Component
13// ============================================
14class ArticleSidenotes extends HTMLElement {
15 constructor() {
16 super();
17 this.attachShadow({ mode: 'open' });
18 this._noteCounter = 0;
19 this._noteMap = new Map();
20 this._resizeTimeout = null;
21 }
22
23 connectedCallback() {
24 this.render();
25 // Wait for DOM to be ready
26 setTimeout(() => {
27 this.processNotes();
28 this.setupPositioning();
29 this.setupHighlighting();
30 }, 0);
31 }
32
33 disconnectedCallback() {
34 if (this._resizeTimeout) {
35 clearTimeout(this._resizeTimeout);
36 }
37 }
38
39 render() {
40 this.shadowRoot.innerHTML = `
41 <style>
42 :host {
43 --content-width: 40rem;
44 --sidenote-width: 18rem;
45 --gap: 2.5rem;
46 --bg: var(--color-bg-primary, #fffffc);
47 --text: var(--color-text-primary, #1a1a1a);
48 --text-muted: var(--color-text-muted, #666);
49 --accent: var(--color-accent, #090c8d);
50 --sidenote-bg: var(--color-bg-secondary, #fafafa);
51 --border: var(--color-border, #e0e0e0);
52
53 display: block;
54 position: relative;
55 max-width: calc(var(--content-width) + var(--gap) + var(--sidenote-width));
56 margin: 0 auto;
57 padding: 0 1rem;
58 }
59
60 .content {
61 max-width: var(--content-width);
62 }
63
64 .sidenotes-container {
65 display: none;
66 }
67
68 /* Only show sidenotes in margin when there's enough space */
69 /* 40rem + 2.5rem + 18rem + 2rem padding = 62.5rem ≈ 1000px */
70 @media (min-width: 1000px) {
71 :host {
72 padding: 0 1rem 0 0.5rem;
73 }
74
75 .sidenotes-container {
76 display: block;
77 position: absolute;
78 left: calc(var(--content-width) + var(--gap));
79 top: 0;
80 width: var(--sidenote-width);
81 height: 100%;
82 }
83
84 .sidenote-wrapper {
85 position: absolute;
86 width: 100%;
87 font-size: 0.85rem;
88 line-height: 1.45;
89 color: var(--text-muted);
90 padding-left: 1rem;
91 border-left: 2px solid var(--border);
92 transition: all 0.2s ease;
93 }
94
95 .sidenote-wrapper:hover {
96 border-left-color: var(--accent);
97 color: var(--text);
98 }
99
100 .sidenote-number {
101 display: inline;
102 font-weight: bold;
103 color: var(--accent);
104 margin-right: 0.3em;
105 }
106
107 .sidenote-content {
108 display: inline;
109 }
110
111 .sidenote-content img {
112 display: block;
113 width: 100%;
114 height: auto;
115 margin: 0.5rem 0;
116 border-radius: 4px;
117 }
118
119 .sidenote-content pre {
120 background: var(--color-code-block-bg, rgba(0, 0, 0, 0.03));
121 padding: 0.5rem;
122 border-radius: 4px;
123 overflow-x: auto;
124 }
125 }
126
127 ::slotted(*) {
128 /* Ensure slotted content inherits styles */
129 }
130 </style>
131
132 <div class="content">
133 <slot></slot>
134 </div>
135 <div class="sidenotes-container" id="sidenotes-container"></div>
136 `;
137 }
138
139 processNotes() {
140 // Auto-number sidenotes and refs
141 let counter = 1;
142
143 // Find all sidenote elements
144 const sidenotes = this.querySelectorAll('side-note');
145 const refs = this.querySelectorAll('sidenote-ref');
146
147 // Create a map of note IDs to numbers
148 sidenotes.forEach(note => {
149 const id = note.getAttribute('id');
150 if (id) {
151 this._noteMap.set(id, counter);
152 note.setAttribute('data-number', counter);
153 counter++;
154 }
155 });
156
157 // Apply numbers to refs
158 refs.forEach(ref => {
159 const forId = ref.getAttribute('for');
160 if (forId && this._noteMap.has(forId)) {
161 ref.setAttribute('data-number', this._noteMap.get(forId));
162 }
163 });
164 }
165
166 setupPositioning() {
167 const container = this.shadowRoot.getElementById('sidenotes-container');
168 const sidenotes = this.querySelectorAll('side-note');
169
170 // Calculate minimum width needed for sidenotes in margin
171 // 40rem + 2.5rem + 18rem + 1.5rem padding = 62rem ≈ 1000px
172 const minWidthForSidenotes = 1000;
173
174 // Clear existing clones
175 container.innerHTML = '';
176
177 // Only clone if we have enough space
178 if (window.innerWidth >= minWidthForSidenotes) {
179 // Clone sidenotes to the container for desktop display
180 sidenotes.forEach(note => {
181 // Create a wrapper div to hold the sidenote
182 const wrapper = document.createElement('div');
183 wrapper.className = 'sidenote-wrapper';
184 wrapper.setAttribute('data-number', note.getAttribute('data-number'));
185 wrapper.setAttribute('data-id', note.getAttribute('id'));
186
187 // Copy the inner HTML directly to preserve content
188 wrapper.innerHTML = `<span class="sidenote-number">${note.getAttribute('data-number')}.</span><span class="sidenote-content">${note.innerHTML}</span>`;
189
190 container.appendChild(wrapper);
191 });
192
193 // Position sidenotes
194 this.positionSidenotes();
195 }
196
197 // Reposition on resize and scroll
198 let resizeTimeout;
199 const repositionHandler = () => {
200 clearTimeout(resizeTimeout);
201 resizeTimeout = setTimeout(() => {
202 const currentWidth = window.innerWidth;
203
204 if (currentWidth >= minWidthForSidenotes) {
205 // Re-setup if transitioning from mobile to desktop
206 if (container.children.length === 0) {
207 this.setupPositioning();
208 } else {
209 this.positionSidenotes();
210 }
211 } else {
212 // Clear sidenotes container when in mobile view
213 container.innerHTML = '';
214 }
215 }, 150);
216 };
217
218 window.addEventListener('resize', repositionHandler);
219 window.addEventListener('scroll', repositionHandler, { passive: true });
220
221 // Also reposition after all resources load
222 window.addEventListener('load', () => this.positionSidenotes());
223 }
224
225 positionSidenotes() {
226 const minWidthForSidenotes = 1000;
227 if (window.innerWidth < minWidthForSidenotes) return;
228
229 const container = this.shadowRoot.getElementById('sidenotes-container');
230 const containerRect = container.getBoundingClientRect();
231 const containerTop = containerRect.top + window.scrollY;
232
233 // Collect reference-sidenote pairs
234 const pairs = [];
235 this.querySelectorAll('sidenote-ref').forEach(ref => {
236 const forId = ref.getAttribute('for');
237 const sidenote = container.querySelector(`.sidenote-wrapper[data-id="${forId}"]`);
238
239 if (sidenote) {
240 const refRect = ref.getBoundingClientRect();
241 const refTop = refRect.top + window.scrollY;
242
243 pairs.push({
244 sidenote,
245 targetTop: refTop - containerTop
246 });
247 }
248 });
249
250 // Sort by position and place with overlap prevention
251 pairs.sort((a, b) => a.targetTop - b.targetTop);
252
253 let lastBottom = 0;
254 pairs.forEach(({ sidenote, targetTop }) => {
255 const adjustedTop = Math.max(targetTop, lastBottom + 20);
256 sidenote.style.top = adjustedTop + 'px';
257
258 const height = sidenote.offsetHeight || 100;
259 lastBottom = adjustedTop + height;
260 });
261 }
262
263 setupHighlighting() {
264 // Setup hover events on wrapper elements in shadow DOM
265 const container = this.shadowRoot.getElementById('sidenotes-container');
266
267 container.addEventListener('mouseenter', (e) => {
268 const wrapper = e.target.closest('.sidenote-wrapper');
269 if (wrapper) {
270 const number = wrapper.getAttribute('data-number');
271 // Highlight corresponding reference
272 const ref = this.querySelector(`sidenote-ref[data-number="${number}"]`);
273 if (ref) ref.highlighted = true;
274 }
275 }, true);
276
277 container.addEventListener('mouseleave', (e) => {
278 const wrapper = e.target.closest('.sidenote-wrapper');
279 if (wrapper) {
280 const number = wrapper.getAttribute('data-number');
281 // Unhighlight corresponding reference
282 const ref = this.querySelector(`sidenote-ref[data-number="${number}"]`);
283 if (ref) ref.highlighted = false;
284 }
285 }, true);
286
287 // Listen for ref hover events - currently not needed but kept for future use
288 }
289}
290
291// ============================================
292// SIDENOTE-REF - Reference Component
293// ============================================
294class SidenoteRef extends HTMLElement {
295 static get observedAttributes() {
296 return ['data-number'];
297 }
298
299 constructor() {
300 super();
301 this.attachShadow({ mode: 'open' });
302 }
303
304 connectedCallback() {
305 this.render();
306 this.setupEvents();
307 }
308
309 attributeChangedCallback(name, oldValue, newValue) {
310 if (name === 'data-number' && oldValue !== newValue) {
311 this.render();
312 }
313 }
314
315 get highlighted() {
316 return this.hasAttribute('highlighted');
317 }
318
319 set highlighted(value) {
320 if (value) {
321 this.setAttribute('highlighted', '');
322 } else {
323 this.removeAttribute('highlighted');
324 }
325 this.updateHighlight();
326 }
327
328 render() {
329 const number = this.getAttribute('data-number') || '';
330
331 this.shadowRoot.innerHTML = `
332 <style>
333 :host {
334 display: inline-block;
335 vertical-align: baseline;
336 font-size: 0.65em;
337 font-weight: 500;
338 margin: 0 0.4em;
339 }
340
341 .ref {
342 display: inline-flex;
343 align-items: center;
344 justify-content: center;
345 width: 1.1em;
346 height: 1.1em;
347 padding: 0;
348 color: var(--accent, #090c8d);
349 background: rgba(9, 12, 141, 0.08);
350 border: 1px solid rgba(9, 12, 141, 0.2);
351 border-radius: 50%;
352 cursor: pointer;
353 text-decoration: none;
354 transition: all 0.2s ease;
355 font-variant-numeric: tabular-nums;
356 line-height: 1;
357 margin-left: 0.2em;
358 margin-right: 0.2em;
359 }
360
361 .ref:hover,
362 :host([highlighted]) .ref {
363 background: var(--accent, #090c8d);
364 border-color: var(--accent, #0066cc);
365 color: white;
366 transform: scale(1.05);
367 }
368 </style>
369 <span class="ref">${number}</span>
370 `;
371 }
372
373 setupEvents() {
374 this.addEventListener('mouseenter', () => {
375 const number = this.getAttribute('data-number');
376 this.dispatchEvent(new CustomEvent('ref-hover', {
377 detail: { number, active: true },
378 bubbles: true
379 }));
380 });
381
382 this.addEventListener('mouseleave', () => {
383 const number = this.getAttribute('data-number');
384 this.dispatchEvent(new CustomEvent('ref-hover', {
385 detail: { number, active: false },
386 bubbles: true
387 }));
388 });
389 }
390
391 updateHighlight() {
392 // Force re-render to show highlight state
393 const ref = this.shadowRoot.querySelector('.ref');
394 if (ref) {
395 ref.style.background = this.highlighted ? 'var(--accent, #0066cc)' : '';
396 ref.style.color = this.highlighted ? 'white' : '';
397 }
398 }
399}
400
401// ============================================
402// SIDE-NOTE - Note Content Component
403// ============================================
404class SideNote extends HTMLElement {
405 static get observedAttributes() {
406 return ['data-number'];
407 }
408
409 constructor() {
410 super();
411 this.attachShadow({ mode: 'open' });
412 }
413
414 connectedCallback() {
415 this.render();
416 }
417
418 attributeChangedCallback(name, oldValue, newValue) {
419 if (name === 'data-number' && oldValue !== newValue) {
420 this.render();
421 }
422 }
423
424 render() {
425 const number = this.getAttribute('data-number') || '';
426
427 this.shadowRoot.innerHTML = `
428 <style>
429 :host {
430 display: block;
431 font-size: 0.85rem;
432 line-height: 1.5;
433 color: var(--text-muted, #777);
434 margin: 2.5rem 0 3.5rem 0;
435 padding-left: 1.5rem;
436 border-left: 2px solid var(--border, #e0e0e0);
437 position: relative;
438 transition: all 0.2s ease;
439 opacity: 0.9;
440 font-style: italic;
441 }
442
443 /* Hide on desktop - they're shown in the sidebar */
444 @media (min-width: 1000px) {
445 :host {
446 display: none;
447 }
448 }
449
450 /* Hover effect similar to two-column layout */
451 :host(:hover) {
452 border-left-color: var(--accent, #090c8d);
453 opacity: 1;
454 }
455
456 /* Dark mode adjustments */
457 @media (prefers-color-scheme: dark) {
458 :host {
459 color: #999;
460 }
461 }
462
463 [data-theme="dark"] :host {
464 color: #999;
465 }
466
467 .sidenote-mobile-number {
468 display: inline-block;
469 font-weight: bold;
470 color: var(--accent, #090c8d);
471 margin-right: 0.5em;
472 font-size: 0.9em;
473 }
474
475 /* Mobile/tablet styles - ensure adequate spacing */
476 @media (max-width: 999px) {
477 :host {
478 /* Provide generous spacing between notes */
479 margin: 3rem 0;
480 /* Slightly indent from main text */
481 margin-left: 1rem;
482 /* Don't make it full width to distinguish from main text */
483 max-width: calc(100% - 2rem);
484 }
485
486 /* Reduce top margin for first sidenote after text */
487 p + :host,
488 blockquote + :host,
489 pre + :host {
490 margin-top: 2rem;
491 }
492
493 .sidenote-mobile-number {
494 display: inline;
495 }
496 }
497
498 @media (min-width: 1000px) {
499 .sidenote-mobile-number {
500 display: none;
501 }
502 }
503
504 /* Slotted content styling - keep content compact */
505 ::slotted(*) {
506 margin: 0.4rem 0;
507 font-size: inherit;
508 line-height: inherit;
509 color: inherit;
510 }
511
512 ::slotted(p:first-child),
513 ::slotted(*:first-child) {
514 margin-top: 0;
515 }
516
517 ::slotted(p:last-child),
518 ::slotted(*:last-child) {
519 margin-bottom: 0;
520 }
521
522 ::slotted(img) {
523 display: block;
524 width: 100%;
525 height: auto;
526 border-radius: 4px;
527 margin: 0.75rem 0;
528 }
529
530 ::slotted(pre) {
531 background: var(--color-code-block-bg, rgba(0, 0, 0, 0.03));
532 padding: 0.4rem;
533 border-radius: 4px;
534 overflow-x: auto;
535 font-size: 0.85em;
536 margin: 0.5rem 0;
537 }
538
539 ::slotted(blockquote) {
540 border-left: 2px solid var(--accent, #090c8d);
541 padding-left: 0.75rem;
542 margin-left: 0;
543 font-style: italic;
544 opacity: 0.9;
545 }
546
547 ::slotted(code) {
548 font-size: 0.85em;
549 padding: 0.1em 0.3em;
550 background: rgba(0, 0, 0, 0.05);
551 border-radius: 3px;
552 }
553
554 @media (prefers-color-scheme: dark) {
555 ::slotted(code) {
556 background: rgba(255, 255, 255, 0.1);
557 }
558 }
559 </style>
560 <div style="padding-left: 0.75rem;">
561 ${number ? `<span class="sidenote-mobile-number">${number}.</span>` : ''}
562 <slot></slot>
563 </div>
564 `;
565 }
566}
567
568// Register components with proper hyphenated names
569customElements.define('article-sidenotes', ArticleSidenotes);
570customElements.define('sidenote-ref', SidenoteRef);
571customElements.define('side-note', SideNote);