1// Share button web component + related-projects widget. 2// Usage: <share-button label="ð¤ Share" theme="gradient"></share-button> 3// 4// ShareButtonElement used to live here, then this file was rewritten as the 5// related-projects FAB only â every <share-button> on the site became an 6// empty undefined custom element. StepMania's CLS reserve then held a blank 7// 6.5rem hole in the toolbar. Restore the CE first; the widget IIFE follows. 8 9class ShareButtonElement extends HTMLElement { 10 static get observedAttributes() { 11 return ['title', 'text', 'url', 'theme', 'label']; 12 } 13 14 constructor() { 15 super(); 16 this.attachShadow({ mode: 'open' }); 17 this._urlGenerator = null; 18 this._textGenerator = null; 19 this._titleGenerator = null; 20 } 21 22 connectedCallback() { 23 if (window.self !== window.top) { 24 this.style.display = 'none'; 25 return; 26 } 27 this.render(); 28 this.bindEvents(); 29 } 30 31 attributeChangedCallback() { 32 if (this.shadowRoot.innerHTML) { 33 this.render(); 34 this.bindEvents(); 35 } 36 } 37 38 set urlGenerator(fn) { 39 this._urlGenerator = fn; 40 } 41 get urlGenerator() { 42 return this._urlGenerator; 43 } 44 set textGenerator(fn) { 45 this._textGenerator = fn; 46 } 47 get textGenerator() { 48 return this._textGenerator; 49 } 50 set titleGenerator(fn) { 51 this._titleGenerator = fn; 52 } 53 get titleGenerator() { 54 return this._titleGenerator; 55 } 56 57 get shareTitle() { 58 if (typeof this._titleGenerator === 'function') return this._titleGenerator(); 59 return this.getAttribute('title') || document.title; 60 } 61 62 get shareText() { 63 if (typeof this._textGenerator === 'function') return this._textGenerator(); 64 return this.getAttribute('text') || ''; 65 } 66 67 get shareUrl() { 68 if (typeof this._urlGenerator === 'function') return this._urlGenerator(); 69 if (this.getAttribute('url')) return this.getAttribute('url'); 70 if (typeof window.buildSharedUrl === 'function') { 71 return window.buildSharedUrl('share_button'); 72 } 73 return window.location.href; 74 } 75 76 get theme() { 77 const t = this.getAttribute('theme') || 'primary'; 78 return t === 'gradient' ? 'primary' : t; 79 } 80 81 get label() { 82 return this.getAttribute('label') || 'ð¤ Share'; 83 } 84 85 extractIcon(label) { 86 const emojiMatch = label.match( 87 /[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/u 88 ); 89 return emojiMatch ? emojiMatch[0] : label.charAt(0); 90 } 91 92 extractText(label) { 93 const emojiMatch = label.match( 94 /[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/u 95 ); 96 if (emojiMatch) return label.replace(emojiMatch[0], '').trim(); 97 return label; 98 } 99 100 getThemeStyles() { 101 const themes = { 102 primary: ` 103 background: var(--accent-primary-bg); 104 color: var(--text-on-accent); 105 border: 1px solid var(--accent-primary-bg-hover); 106 `, 107 dark: ` 108 background: var(--surface-1); 109 color: var(--accent-primary); 110 border: 1px solid var(--accent-primary); 111 `, 112 light: ` 113 background: var(--surface-1); 114 color: var(--accent-primary); 115 border: 1px solid var(--accent-primary); 116 `, 117 glass: ` 118 background: var(--accent-primary-bg); 119 color: var(--text-on-accent); 120 border: 1px solid var(--accent-primary-bg-hover); 121 `, 122 retro: ` 123 background: var(--surface-2, #1a1a2e); 124 color: var(--success, #00ff00); 125 border: 2px solid var(--success, #00ff00); 126 font-family: var(--font-mono), 'Courier New', monospace; 127 `, 128 paper: ` 129 background: var(--accent-primary-bg); 130 color: var(--text-on-accent); 131 border: 1px solid var(--accent-primary-bg-hover); 132 ` 133 }; 134 return themes[this.theme] || themes.primary; 135 } 136 137 render() { 138 this.shadowRoot.innerHTML = ` 139 <style> 140 /* Reset the gradient-text trick (-webkit-background-clip: text + 141 -webkit-text-fill-color: transparent) if an ancestor uses it. 142 -webkit-text-fill-color is inherited and crosses the shadow 143 boundary, so a <share-button> placed inside a gradient <h1> 144 (periodic-speller) rendered as an accent-colored box with 145 invisible label text. */ 146 :host { 147 display: inline-block; 148 -webkit-text-fill-color: currentColor; 149 } 150 .share-btn { 151 ${this.getThemeStyles()} 152 box-sizing: border-box; 153 font-family: var(--font-ui), -apple-system, 'Segoe UI', Roboto, sans-serif; 154 font-weight: 600; 155 padding: 0.5rem 1.25rem; 156 border-radius: var(--radius, 0.75rem); 157 transition: all 0.2s ease; 158 transform: scale(1); 159 cursor: pointer; 160 font-size: 14px; 161 display: inline-flex; 162 align-items: center; 163 justify-content: center; 164 gap: 0.5rem; 165 height: 2.5rem; 166 min-height: 2.5rem; 167 white-space: nowrap; 168 } 169 .share-btn .icon-only { display: none; } 170 @media (max-width: 640px) { 171 .share-btn { 172 padding: 0.5rem !important; 173 min-width: 2.5rem !important; 174 min-height: 2.5rem !important; 175 justify-content: center !important; 176 } 177 .share-btn .text-label { display: none !important; } 178 .share-btn .icon-only { 179 display: inline-block !important; 180 font-size: 1.25rem !important; 181 } 182 } 183 @media (max-width: 768px) { 184 .share-btn { 185 padding: 0.4rem 0.9rem; 186 font-size: 13px; 187 min-height: 2.25rem; 188 gap: 0.35rem; 189 } 190 } 191 .share-btn:hover { 192 transform: scale(1.04); 193 filter: brightness(0.96); 194 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); 195 } 196 .share-btn:active { transform: scale(0.95); } 197 .share-btn:focus-visible { 198 outline: 2px solid var(--focus-ring-inner, #1a73e8); 199 outline-offset: 2px; 200 box-shadow: 0 0 0 4px var(--focus-ring-outer, rgba(26, 115, 232, 0.25)); 201 } 202 .tooltip { 203 position: fixed; 204 background: var(--surface-2, #1f2937); 205 color: var(--text-1, #fff); 206 font-size: 0.8rem; 207 border-radius: var(--radius, 0.5rem); 208 padding: 0.5rem 0.75rem; 209 opacity: 0; 210 visibility: hidden; 211 transition: all 0.2s ease; 212 pointer-events: none; 213 white-space: nowrap; 214 z-index: 10000; 215 transform: translateX(-50%); 216 } 217 .tooltip.show { opacity: 1; visibility: visible; } 218 .tooltip.success { background: var(--success, #10b981); color: #fff; } 219 .tooltip.error { background: var(--danger, #ef4444); color: #fff; } 220 </style> 221 <button type="button" class="share-btn" id="share-btn" aria-label="Share this page">
222 <span class="icon-only">${this.extractIcon(this.label)}</span> 223 <span class="text-label">${this.extractText(this.label)}</span> 224 </button> 225 <div class="tooltip" id="tooltip" role="status"></div> 226 `; 227 } 228 229 bindEvents() { 230 const btn = this.shadowRoot.getElementById('share-btn'); 231 btn?.addEventListener('click', (e) => { 232 e.preventDefault(); 233 e.stopPropagation(); 234 void this.handleShare(); 235 }); 236 } 237 238 async handleShare() { 239 if (typeof window.trackConversion === 'function') { 240 window.trackConversion('content_shared', 1); 241 } 242 243 const shareData = { 244 title: this.shareTitle, 245 text: this.shareText, 246 url: this.shareUrl 247 }; 248 249 const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( 250 navigator.userAgent 251 ); 252 if (navigator.share && window.isSecureContext && isMobile) { 253 try { 254 await navigator.share(shareData); 255 this.showTooltip('Shared!', 'success'); 256 return; 257 } catch (err) { 258 if (err && err.name === 'AbortError') return; 259 } 260 } 261 262 await this.copyToClipboard(); 263 } 264 265 async copyToClipboard() { 266 const textToCopy = this.shareText ? `${this.shareText} ${this.shareUrl}` : this.shareUrl; 267 try { 268 if (navigator.clipboard && window.isSecureContext) { 269 await navigator.clipboard.writeText(textToCopy); 270 } else { 271 const input = document.createElement('input'); 272 input.value = textToCopy; 273 input.style.position = 'fixed'; 274 input.style.left = '-9999px'; 275 document.body.appendChild(input); 276 input.select(); 277 document.execCommand('copy'); 278 document.body.removeChild(input); 279 } 280 this.showTooltip('Link copied!', 'success'); 281 } catch (error) { 282 console.error('Failed to copy:', error); 283 this.showTooltip('Copy failed', 'error'); 284 } 285 } 286 287 showTooltip(message, type) { 288 const tooltip = this.shadowRoot.getElementById('tooltip'); 289 const button = this.shadowRoot.getElementById('share-btn'); 290 if (!tooltip || !button) return; 291 tooltip.textContent = message; 292 tooltip.className = `tooltip ${type} show`; 293 const rect = button.getBoundingClientRect(); 294 tooltip.style.left = `${rect.left + rect.width / 2}px`; 295 tooltip.style.top = `${rect.top - 40}px`; 296 setTimeout(() => tooltip.classList.remove('show'), 2000); 297 } 298} 299 300if (!customElements.get('share-button')) { 301 customElements.define('share-button', ShareButtonElement); 302} 303window.ShareButton = ShareButtonElement; 304 305// Related Projects Component 306// Include this script on project pages to suggest related projects 307 308(function () { 309 'use strict'; 310 311 // Capture our own <script> tag immediately. `document.currentScript` is 312 // only defined during the synchronous execution of this file, so we read 313 // its data attributes here before any async DOM work could shadow it. 314 // Matches the convention used by /nav.js (`data-nav-size="compact"`). 315 const scriptTag = document.currentScript; 316 317 // Only run once 318 if (window.relatedProjectsInitialized) return; 319 window.relatedProjectsInitialized = true; 320 321 // Don't show on the home page 322 if (window.location.pathname === '/' || window.location.pathname === '/index.html') { 323 return; 324 } 325 326 // Generic opt-out for the one-click share FAB. A page that ships its own 327 // dedicated share affordance (e.g. doom/share-button.js) can set 328 // `data-share-fab="off"` on the share.js <script> tag to suppress the 329 // generic floating button. The related-projects ð¯ panel still renders 330 // so cross-app discovery is preserved. No page-specific allow-list lives 331 // in this file. 332 const shareFabSuppressed = scriptTag?.dataset.shareFab === 'off'; 333 334 // Kick off the registry fetch as early as possible, in parallel with the 335 // DOMContentLoaded wait. Previously this was a synchronous XHR which 336 // blocked DOMContentLoaded on every page that included share.js â that 337 // delayed first paint and stretched the timeline so other shifts (font 338 // swap, JS-rendered grids) more easily landed inside CLS's 5-second 339 // measurement window. The widget itself is `position: fixed`, so deferring 340 // its insertion until after the registry resolves never affects document 341 // flow. 342 async function loadAppsRegistry() { 343 try { 344 const res = await fetch('/apps-registry.json', { cache: 'default' }); 345 if (!res.ok) return []; 346 const data = await res.json(); 347 return Array.isArray(data) ? data : []; 348 } catch (e) { 349 console.warn('[share.js] Could not load apps-registry.json', e); 350 return []; 351 } 352 } 353 354 const registryMapsPromise = loadAppsRegistry().then((registryApps) => { 355 const projectRelationships = {}; 356 const projectMetadata = {}; 357 for (const app of registryApps) { 358 const path = app.path || ''; 359 const href = path.startsWith('./') ? '/' + path.slice(2) : path || `/${app.id}/`; 360 projectMetadata[app.id] = { 361 name: app.shortName || app.name, 362 icon: app.icon || 'ð¦', 363 description: app.description || '', 364 href 365 }; 366 if (app.related && app.related.length) { 367 projectRelationships[app.id] = { 368 category: app.shareCategory || app.category || 'utility', 369 related: app.related 370 }; 371 } 372 } 373 return { projectRelationships, projectMetadata, registryApps }; 374 }); 375 376 function loadScript(src) { 377 return new Promise((resolve, reject) => { 378 const existing = document.querySelector(`script[src="${src}"]`); 379 if (existing) { 380 if (existing.dataset.loaded === '1' || window.HeymingRegistryPath) { 381 resolve(); 382 return; 383 } 384 existing.addEventListener('load', () => resolve(), { once: true }); 385 existing.addEventListener('error', () => reject(new Error(`Failed to load ${src}`)), { 386 once: true 387 }); 388 return; 389 } 390 const script = document.createElement('script'); 391 script.src = src; 392 script.async = false;
393 const timer = setTimeout(() => reject(new Error(`Timeout loading ${src}`)), 3000); 394 script.addEventListener('load', () => { 395 clearTimeout(timer); 396 script.dataset.loaded = '1'; 397 resolve(); 398 }); 399 script.addEventListener('error', () => { 400 clearTimeout(timer); 401 reject(new Error(`Failed to load ${src}`)); 402 }); 403 document.head.appendChild(script); 404 }); 405 } 406 407 function ensureRegistryPath() { 408 if (window.HeymingRegistryPath) return Promise.resolve(); 409 return loadScript('/shared/registry-path.js').catch((error) => { 410 console.warn('[share.js] Could not load registry-path.js', error); 411 }); 412 } 413 414 // First URL segment only â fallback when registry-path is unavailable. 415 function getCurrentProjectFallback() { 416 const path = window.location.pathname; 417 const segments = path.split('/').filter((s) => s); 418 return segments[0] || null; 419 } 420 421 function resolveCurrentProject(registryApps) { 422 const api = window.HeymingRegistryPath; 423 if (api && Array.isArray(registryApps)) { 424 return api.resolveAppIdFromLocation( 425 registryApps, 426 { pathname: window.location.pathname, search: window.location.search }, 427 { fallback: () => getCurrentProjectFallback() } 428 ); 429 } 430 return getCurrentProjectFallback(); 431 } 432 433 // Create related projects widget 434 function createRelatedProjects(projectRelationships, projectMetadata, registryApps) { 435 const currentProject = resolveCurrentProject(registryApps); 436 if (!currentProject || !projectRelationships[currentProject]) { 437 return null; 438 } 439 440 const relatedIds = projectRelationships[currentProject].related; 441 if (!relatedIds || relatedIds.length === 0) { 442 return null; 443 } 444 445 // Take first 3 related projects 446 const relatedProjects = relatedIds.slice(0, 3).map((id) => ({ 447 id, 448 ...projectMetadata[id] 449 })); 450 451 const container = document.createElement('div'); 452 container.className = 'related-projects-container'; 453 container.id = 'related-projects-widget'; 454 container.innerHTML = ` 455 <button class="related-projects-toggle" aria-label="View related projects" title="More projects like this"> 456 ð¯ 457 </button> 458 <div class="related-projects-panel" aria-hidden="true"> 459 <button class="related-projects-close" aria-label="Close suggestions" title="Close"> 460 à 461 </button> 462 <div class="related-projects-header"> 463 <h3>ð¯ You might also like</h3> 464 </div> 465 <div class="related-projects-grid"> 466 ${relatedProjects 467 .map( 468 (project) => ` 469 <a href="${project.href || `/${project.id}/`}" 470 class="related-project-card"> 471 <div class="related-project-icon">${project.icon}</div> 472 <div class="related-project-info"> 473 <div class="related-project-name">${project.name}</div> 474 <div class="related-project-description">${project.description}</div> 475 </div> 476 </a> 477 ` 478 ) 479 .join('')} 480 </div> 481 <div class="related-projects-footer"> 482 <a href="/"> 483 View all projects â 484 </a> 485 <button class="share-btn-mini" id="share-url-btn-mini" title="Copy link to clipboard"> 486 ð Share 487 </button> 488 <button class="feedback-btn-mini" id="feedback-btn-mini" title="Send Feedback"> 489 ð¬ Feedback 490 </button> 491 </div> 492 </div> 493 `; 494 495 // Toggle functionality 496 const toggleBtn = container.querySelector('.related-projects-toggle'); 497 const panel = container.querySelector('.related-projects-panel'); 498 499 function closePanel() { 500 panel.classList.remove('open'); 501 panel.setAttribute('aria-hidden', 'true'); 502 document.body.classList.remove('related-panel-open'); 503 document.removeEventListener('click', closePanelFromOutside); 504 document.removeEventListener('keydown', closePanelOnEscape); 505 } 506 507 function closePanelFromOutside(e) { 508 if (!container.contains(e.target)) closePanel(); 509 } 510 511 function closePanelOnEscape(e) { 512 if (e.key !== 'Escape') return; 513 closePanel(); 514 e.stopPropagation(); 515 } 516
517 function openPanel() { 518 panel.classList.add('open'); 519 panel.setAttribute('aria-hidden', 'false'); 520 // Signal canvas/WebGL apps to demote their surfaces â on mobile WebKit 521 // accelerated canvases can composite above position:fixed UI regardless 522 // of z-index (see untangle/ and similar full-bleed canvas pages). 523 document.body.classList.add('related-panel-open'); 524 document.addEventListener('click', closePanelFromOutside); 525 document.addEventListener('keydown', closePanelOnEscape); 526 } 527 528 toggleBtn.addEventListener('click', (e) => { 529 e.preventDefault(); 530 e.stopPropagation(); // Prevent triggering parent elements 531 const isOpen = panel.classList.contains('open'); 532 533 if (isOpen) { 534 closePanel(); 535 } else { 536 openPanel(); 537 } 538 }); 539 540 // Close button functionality 541 const closeBtn = container.querySelector('.related-projects-close'); 542 closeBtn.addEventListener('click', (e) => { 543 e.preventDefault(); 544 e.stopPropagation(); 545 closePanel(); 546 }); 547 548 // Prevent clicks inside panel from closing it 549 panel.addEventListener('click', (e) => { 550 e.stopPropagation(); 551 }); 552 553 // Share button: copy current URL to clipboard 554 const shareBtn = container.querySelector('#share-url-btn-mini'); 555 if (shareBtn) { 556 shareBtn.addEventListener('click', (e) => { 557 e.preventDefault(); 558 e.stopPropagation(); 559 // Tag the URL so visitors who paste it land with `shared=1&share_source=related_widget`, 560 // which `trackSharedLinkArrival` in analytics.js converts to `shared_link_arrival`. 561 const url = 562 typeof window.buildSharedUrl === 'function' 563 ? window.buildSharedUrl('related_widget') 564 : window.location.href; 565 navigator.clipboard 566 .writeText(url) 567 .then(() => { 568 const origText = shareBtn.innerHTML; 569 shareBtn.innerHTML = 'â Copied!'; 570 shareBtn.disabled = true; 571 setTimeout(() => { 572 shareBtn.innerHTML = origText; 573 shareBtn.disabled = false; 574 }, 1500); 575 }) 576 .catch(() => { 577 shareBtn.innerHTML = 'Copy failed'; 578 setTimeout(() => { 579 shareBtn.innerHTML = 'ð Share'; 580 }, 2000); 581 }); 582 if (window.trackConversion) { 583 window.trackConversion('content_shared', 1); 584 } 585 }); 586 } 587 588 // Feedback button functionality 589 const feedbackBtn = container.querySelector('#feedback-btn-mini'); 590 if (feedbackBtn) { 591 feedbackBtn.addEventListener('click', (e) => { 592 e.preventDefault(); 593 e.stopPropagation(); 594 595 // Close the related-projects panel first so it doesnât cover the modal 596 closePanel(); 597 598 // Prefer existing feedback-button on the page;
598 call openModal() directly 599 const existingFeedback = document.querySelector('feedback-button'); 600 if (existingFeedback && typeof existingFeedback.openModal === 'function') { 601 existingFeedback.openModal(); 602 return; 603 } 604 605 // Fallback: create an instance with trigger hidden (so modal can paint). Do not use display:none on host or the modal wonât show. 606 if (typeof window.FeedbackButton !== 'undefined') { 607 const tempFeedback = document.createElement('feedback-button'); 608 tempFeedback.setAttribute('label', 'ð¬ Feedback'); 609 tempFeedback.setAttribute('theme', 'gradient'); 610 tempFeedback.setAttribute('hide-trigger', ''); 611 document.body.appendChild(tempFeedback); 612 requestAnimationFrame(() => { 613 if (typeof tempFeedback.openModal === 'function') { 614 tempFeedback.openModal(); 615 } 616 }); 617 } else { 618 alert('Feedback feature is loading. Please try again in a moment.'); 619 } 620 }); 621 } 622 623 return container; 624 } 625 626 // Add styles 627 // 628 // Phase 3 of the brand pivot re-skinned the related-projects widget 629 // for the new heyming-engineering palette: paper surfaces, blue 630 // accents, no glass/gradients. Geometry preserved (positioning, 631 // toggle size, slide animation). 632 const style = document.createElement('style'); 633 style.textContent = ` 634 .related-projects-container { 635 position: fixed; 636 bottom: 20px; 637 right: 20px; 638 z-index: 9000; 639 font-family: var(--font-ui), -apple-system, 'Segoe UI', Roboto, sans-serif; 640 } 641 642 /* Adjust position if there's another button in bottom-right (like info button) */ 643 body:has(.info-btn) .related-projects-container, 644 body:has(#info-btn) .related-projects-container { 645 bottom: 85px; 646 } 647 648 .related-projects-toggle { 649 width: 56px; 650 height: 56px; 651 border-radius: 50%; 652 border: 1px solid var(--accent-primary-bg-hover); 653 background: var(--accent-primary-bg); 654 color: var(--text-on-accent); 655 font-size: 24px; 656 cursor: pointer; 657 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); 658 transition: all 0.3s ease; 659 display: flex; 660 align-items: center; 661 justify-content: center; 662 position: relative; 663 z-index: 9001; 664 } 665 666 .related-projects-toggle:hover { 667 transform: scale(1.05); 668 background: var(--accent-primary-bg-hover); 669 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); 670 } 671 672 .related-projects-toggle:active { 673 transform: scale(1.02); 674 } 675 676 .related-projects-panel { 677 position: absolute; 678 bottom: 70px; 679 right: 0; 680 max-width: 360px; 681 width: 360px; 682 background: var(--surface-1); 683 border-radius: var(--radius-lg); 684 padding: 20px; 685 box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); 686 border: 1px solid var(--hairline-strong); 687 opacity: 0; 688 visibility: hidden; 689 transform: translateY(10px) scale(0.95); 690 pointer-events: none; 691 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); 692 } 693 694 .related-projects-panel.open { 695 opacity: 1; 696 visibility: visible; 697 transform: translateY(0) scale(1); 698 pointer-events: auto; 699 } 700 701 .related-projects-close { 702 position: absolute; 703 top: 8px; 704 right: 8px; 705 width: 28px; 706 height: 28px; 707 border: 1px solid transparent; 708 background: transparent; 709 color: var(--text-2); 710 border-radius: 50%; 711 font-size: 20px; 712 line-height: 1; 713 cursor: pointer; 714 display: flex; 715 align-items: center; 716 justify-content: center; 717 transition: all 0.2s ease; 718 padding: 0; 719 font-weight: 300; 720 } 721 722 .related-projects-close:hover { 723 background: var(--accent-primary-soft); 724 color: var(--accent-primary-hover); 725 } 726 727 .related-projects-header h3 { 728 margin: 0 0 16px 0; 729 font-family: var(--font-display), Georgia, serif; 730 font-size: 18px; 731 font-weight: 700; 732 color: var(--text-1); 733 letter-spacing: -0.005em; 734 } 735 736 .related-projects-grid { 737 display: flex; 738 flex-direction: column; 739 gap: 8px; 740 margin-bottom: 16px; 741 } 742 743 .related-project-card { 744 display: flex; 745 align-items: center; 746 gap: 12px; 747 padding: 10px 12px; 748 background: var(--surface-1); 749 border-radius: var(--radius); 750 text-decoration: none; 751 color: inherit; 752 transition: all 0.2s ease; 753 border: 1px solid var(--hairline); 754 } 755 756 .related-project-card:hover { 757 transform: translateX(-2px); 758 background: var(--accent-primary-soft); 759 border-color: var(--hairline-accent); 760 } 761 762 .related-project-icon { 763 font-size: 32px; 764 line-height: 1; 765 flex-shrink: 0; 766 } 767 768 .related-project-info { 769 flex: 1; 770 min-width: 0; 771 } 772 773 .related-project-name { 774 font-size: 14px; 775 font-weight: 600; 776 color: var(--text-1); 777 margin-bottom: 2px; 778 white-space: nowrap; 779 overflow: hidden; 780 text-overflow: ellipsis; 781 } 782 783 .related-project-description { 784 font-size: 12px; 785 color: var(--text-2); 786 white-space: nowrap; 787 overflow: hidden; 788 text-overflow: ellipsis; 789 } 790 791 .related-projects-footer { 792 padding-top: 12px; 793 border-top: 1px solid var(--hairline); 794 text-align: center; 795 display: flex; 796 flex-direction: column; 797 gap: 8px; 798 } 799 800 .related-projects-footer a { 801 display: inline-block; 802 font-size: 13px; 803 font-weight: 600; 804 color: var(--accent-primary); 805 text-decoration: underline; 806 text-underline-offset: 2px; 807 padding: 6px 12px; 808 border-radius: var(--radius); 809 transition: all 0.2s ease; 810 } 811 812 .related-projects-footer a:hover { 813 background: #fffacd; 814 color: var(--accent-primary-hover); 815 } 816 817 .share-btn-mini, 818 .feedback-btn-mini { 819 display: inline-block; 820 font-size: 13px; 821 font-weight: 600; 822 color: var(--accent-primary); 823 background: var(--surface-1); 824 border: 1px solid var(--hairline-strong); 825 padding: 6px 12px; 826 border-radius: var(--radius); 827 transition: all 0.2s ease; 828 cursor: pointer; 829 text-align: center; 830 } 831 832 .share-btn-mini:hover:not(:disabled), 833 .feedback-btn-mini:hover { 834 background: var(--accent-primary-soft); 835 border-color: var(--accent-primary); 836 } 837 838 .share-btn-mini:disabled { 839 cursor: default; 840 opacity: 0.9; 841 } 842 843 /* One-click share FAB â lives inside .related-projects-container so it 844 * inherits the bottom-right anchor + the body:has(.info-btn) shift, and 845 * stacks ABOVE the ð¯ toggle. Smaller than the toggle so visual weight 846 * stays on the primary related-projects entry point. */ 847 .share-fab { 848 position: absolute; 849 bottom: 68px; 850 right: 6px; 851 width: 44px; 852 height: 44px; 853 border-radius: 50%; 854 border: 1px solid var(--hairline-strong); 855 background: var(--surface-1); 856 color: var(--accent-primary); 857 font-size: 18px; 858 cursor: pointer; 859 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); 860 transition: background var(--motion-hover, 180ms ease), 861 border-color var(--motion-hover, 180ms ease), 862 transform var(--motion-hover, 180ms ease); 863 display: flex; 864 align-items: center; 865 justify-content: center; 866 z-index: 9001; 867 } 868 .share-fab:hover { 869 background: var(--accent-primary-soft); 870 border-color: var(--accent-primary); 871 transform: scale(1.05); 872 } 873 .share-fab:active { 874 transform: scale(1.02); 875 } 876 .share-fab--success { 877 background: var(--success, #22863a); 878 color: #fff; 879 border-color: var(--success, #22863a); 880 } 881 882 /* Mobile responsive */ 883 @media (max-width: 768px) { 884 .related-projects-container { 885 bottom: 80px; 886 right: 12px; 887 } 888 889 .related-projects-toggle { 890 width: 48px; 891 height: 48px; 892 font-size: 20px; 893 } 894 895 .share-fab { 896 width: 38px; 897 height: 38px; 898 font-size: 16px; 899 bottom: 58px; 900 right: 5px; 901 } 902 903 .related-projects-panel { 904 width: calc(100vw - 24px); 905 right: 0; 906 max-width: none; 907 padding: 16px; 908 } 909 910 .related-projects-header h3 { 911 font-size: 15px; 912 } 913 914 .related-project-icon { 915 font-size: 28px; 916 } 917 918 .related-project-name { 919 font-size: 13px; 920 } 921 922 .related-project-description { 923 font-size: 13px; 924 } 925 } 926 927 /* Print - hide widget */ 928 @media print { 929 .related-projects-container { 930 display: none !important; 931 } 932 } 933 934 /* Hide on very small screens to avoid overlap */ 935 @media (max-height: 600px) { 936 .related-projects-container { 937 display: none; 938 } 939 } 940 941 /* Small mobile adjustment */ 942 @media (max-width: 640px) { 943 .related-projects-container { 944 bottom: 12px; 945 right: 12px; 946 } 947 } 948 `; 949 950 // âââ One-click share FAB ââââââââââââââââââââââââââââââââââââââââââââ 951 // 952 // The existing "ð Share" button lives two clicks deep inside the ð¯ 953 // related-projects panel, so almost nobody finds it (only a couple of 954 // `shared_link_arrival` events per month across the whole site pre-FAB). 955 // This FAB sits next to the ð¯ toggle and shares in one click. 956 // share_source tag is `share_fab` so GA can attribute arrivals. 957 function createShareFab() { 958 if (shareFabSuppressed) return null; 959 const fab = document.createElement('button'); 960 fab.type = 'button'; 961 fab.className = 'share-fab'; 962 fab.setAttribute('aria-label', 'Share this page'); 963 fab.setAttribute('title', 'Share'); 964 fab.innerHTML = 'ð'; 965 966 fab.addEventListener('click', async (e) => { 967 e.preventDefault(); 968 e.stopPropagation(); 969 const url = 970 typeof window.buildSharedUrl === 'function' 971 ? window.buildSharedUrl('share_fab') 972 : window.location.href; 973 974 if (window.trackConversion) { 975 window.trackConversion('content_shared', 1); 976 } 977 978 if (navigator.share && window.isSecureContext) { 979 try { 980 await navigator.share({ url, title: document.title }); 981 return; 982 } catch (err) { 983 if (err && err.name === 'AbortError') return; 984 // Fall through to clipboard. 985 } 986 } 987
988 try { 989 await navigator.clipboard.writeText(url); 990 const original = fab.innerHTML; 991 fab.innerHTML = 'â'; 992 fab.classList.add('share-fab--success'); 993 setTimeout(() => { 994 fab.innerHTML = original; 995 fab.classList.remove('share-fab--success'); 996 }, 1500); 997 } catch (_) { 998 const original = fab.innerHTML; 999 fab.innerHTML = 'â'; 1000 setTimeout(() => { 1001 fab.innerHTML = original; 1002 }, 1500); 1003 } 1004 }); 1005 1006 return fab; 1007 } 1008 1009 // Insert when DOM is ready AND the registry has resolved. Both conditions 1010 // are awaited independently so neither blocks the other. The widget is 1011 // position: fixed, so inserting it slightly later than DOMContentLoaded 1012 // never causes a layout shift. 1013 function domReady() { 1014 if (document.readyState !== 'loading') return Promise.resolve(); 1015 return new Promise((resolve) => { 1016 document.addEventListener('DOMContentLoaded', resolve, { once: true }); 1017 }); 1018 } 1019 1020 async function insertWidget() { 1021 const [{ projectRelationships, projectMetadata, registryApps }] = await Promise.all([ 1022 registryMapsPromise, 1023 ensureRegistryPath(), 1024 domReady() 1025 ]); 1026 const widget = createRelatedProjects(projectRelationships, projectMetadata, registryApps); 1027 if (widget) { 1028 document.head.appendChild(style); 1029 document.body.appendChild(widget); 1030 const fab = createShareFab(); 1031 if (fab) widget.appendChild(fab); 1032 } 1033 } 1034 1035 insertWidget(); 1036})();
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.