PageSourceSearch

https://joeheyming.github.io/share.js

js joeheyming.github.io collected 2026-10-03 09:24:29 UTC 32,186 bytes, 1,036 lines download raw bytes

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.