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