PageGrep

Grep-style complementary search engine for wiki/static sites

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği yüklemek için Tampermonkey gibi bir uzantı yüklemeniz gerekir.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği indirebilmeniz için ayrıca Tampermonkey gibi bir eklenti kurmanız gerekmektedir.

Bu komut dosyasını yüklemek için bir kullanıcı komut dosyası yöneticisi uzantısı yüklemeniz gerekecek.

(Zaten bir kullanıcı komut dosyası yöneticim var, kurmama izin verin!)

Bu stili yüklemek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için Stylus gibi bir uzantı kurmanız gerekir.

Bu stili yükleyebilmek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı kurmanız gerekir.

Bu stili yükleyebilmek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

(Zateb bir user-style yöneticim var, yükleyeyim!)

// ==UserScript==
// @name         PageGrep
// @version      3.3
// @description  Grep-style complementary search engine for wiki/static sites
// @author       Rust1667
// @match        https://retrofmhy.pages.dev/*
// @match        https://fmhy.net/*
// @match        https://fluffle.cc/*
// @match        https://rentry.co/*
// @match        https://rentry.org/*
// @match        https://www.reddit.com/r/*/wiki/*
// @match        https://*.wikipedia.org/wiki/*
// @match        https://github.com/*/*/wiki/*
// @grant        none
// @icon         data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔍</text></svg>
// @namespace http://tampermonkey.net/
// ==/UserScript==


(function () {
  'use strict';

  // ── CONFIG ───────────────────────────────────────────────────────────────────
  const CONTENT_SELECTOR = '#mainScroll';  // adjust per site if needed
  const MAX_RESULTS = 10;
  // ─────────────────────────────────────────────────────────────────────────────

  // ── SHADOW DOM HOST ──────────────────────────────────────────────────────────
  const host = document.createElement('div');
  host.id = 'pagegrep-host';
  host.style.cssText = 'all:initial;position:fixed;z-index:2147483647;';
  document.body.appendChild(host);
  const shadow = host.attachShadow({ mode: 'open' });

  // ── STYLES (scoped inside shadow) ────────────────────────────────────────────
  const styleEl = document.createElement('style');
  styleEl.textContent = `
    *, *::before, *::after { box-sizing: border-box; }

    /* ── FAB ── */
    #wgs-fab {
      position: fixed;
      bottom: 24px;
      right: 24px;
      width: 42px;
      height: 42px;
      border-radius: 50%;
      background: #1a1a2e;
      border: 1px solid #3a3a5c;
      box-shadow: 0 2px 12px rgba(0,0,0,.4);
      color: #7c83fd;
      font-size: 18px;
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: center;
      opacity: 0.35;
      transition: opacity .2s, box-shadow .2s;
      user-select: none;
      touch-action: none;
    }
    #wgs-fab:hover { opacity: 1; box-shadow: 0 4px 20px rgba(0,0,0,.55); }
    #wgs-fab.active { opacity: 1; }

    /* ── SPOTLIGHT OVERLAY ── */
    #wgs-overlay {
      position: fixed;
      inset: 0;
      background: rgba(0,0,0,.45);
      display: flex;
      align-items: flex-start;
      justify-content: center;
      padding-top: 18vh;
      opacity: 0;
      pointer-events: none;
      transition: opacity .18s;
      z-index: 1;
    }
    #wgs-overlay.open {
      opacity: 1;
      pointer-events: all;
    }
    #wgs-spotlight {
      background: var(--wgs-site-bg, #1a1a2e);
      border: 1px solid #3a3a5c;
      border-radius: 12px;
      box-shadow: 0 8px 40px rgba(0,0,0,.6);
      padding: 10px 14px;
      display: flex;
      align-items: center;
      gap: 8px;
      width: min(560px, 90vw);
      font-family: 'Consolas', 'Menlo', monospace;
    }
    #wgs-search-icon {
      color: #7c83fd;
      font-size: 16px;
      flex-shrink: 0;
      opacity: 0.7;
    }
    #wgs-input {
      background: transparent;
      border: none;
      outline: none;
      color: var(--wgs-site-text, #e0e0f0);
      font-size: 15px;
      font-family: inherit;
      width: 100%;
      caret-color: #7c83fd;
    }
    #wgs-input::placeholder { color: #555577; }
    #wgs-kbd {
      color: #3a3a5c;
      font-size: 10px;
      flex-shrink: 0;
      white-space: nowrap;
    }

    /* ── RESULTS PANEL ── */
    #wgs-panel {
      position: fixed;
      top: 0;
      right: 0;
      width: 38.196601vw;
      height: 100vh;
      background: var(--wgs-site-bg, #11111e);
      border-left: 2px solid #3a3a5c;
      box-shadow: -6px 0 40px rgba(0,0,0,.6);
      display: flex;
      flex-direction: column;
      font-family: 'Consolas', 'Menlo', monospace;
      transform: translateX(100%);
      transition: transform .2s cubic-bezier(.4,0,.2,1);
      overflow: hidden;
      z-index: 2;
    }
    #wgs-panel.open { transform: translateX(0); }

    #wgs-panel-header {
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 12px 16px;
      border-bottom: 1px solid #2a2a4e;
      background: var(--wgs-site-bg, #1a1a2e);
      flex-shrink: 0;
    }
    #wgs-panel-title {
      color: #7c83fd;
      font-size: var(--wgs-site-fontsize, 14px);
      letter-spacing: .08em;
      text-transform: uppercase;
    }
    #wgs-close {
      background: none;
      border: none;
      color: #555577;
      font-size: 18px;
      cursor: pointer;
      line-height: 1;
      padding: 2px 6px;
      border-radius: 4px;
      transition: color .15s, background .15s;
    }
    #wgs-close:hover { color: #e0e0f0; background: #2a2a4e; }

    #wgs-results {
      overflow-y: auto;
      flex: 1;
      padding: 8px 0;
    }
    #wgs-results::-webkit-scrollbar { width: 6px; }
    #wgs-results::-webkit-scrollbar-track { background: transparent; }
    #wgs-results::-webkit-scrollbar-thumb { background: #2a2a4e; border-radius: 3px; }

    .wgs-result {
      padding: 9px 16px;
      border-bottom: 1px solid #1e1e34;
      transition: background .1s;
      cursor: pointer;
    }
    .wgs-result:hover { background: color-mix(in srgb, var(--wgs-site-bg, #1e1e34) 85%, white 15%); }

    .wgs-breadcrumb {
      display: block;
      margin-bottom: 2px;
      font-size: calc(var(--wgs-site-fontsize, 14px) * 0.82);
      line-height: 1.5;
    }
    .wgs-breadcrumb-seg {
      color: #7c83fd;
      text-decoration: none;
      font-weight: 600;
      letter-spacing: .03em;
      transition: color .1s;
    }
    .wgs-breadcrumb-seg:hover { color: #a0a6ff; text-decoration: underline; }
    .wgs-breadcrumb-sep {
      color: #3a3a5c;
      margin: 0 4px;
      font-size: 10px;
    }

    .wgs-line li, .wgs-line ul, .wgs-line ol { list-style: none; margin: 0; padding: 0; }
    .wgs-line {
      color: var(--wgs-site-text, #c8c8e0);
      font-size: var(--wgs-site-fontsize, 14px);
      line-height: 1.5;
      word-break: break-word;
    }
    .wgs-line a { color: var(--wgs-site-link, #7ca4fd) !important; }
    .wgs-line a:hover { filter: brightness(1.25); }

    .wgs-match {
      background: #3d3500;
      color: #ffd54f;
      border-radius: 2px;
      padding: 0 1px;
    }

    .wgs-empty {
      color: #555577;
      font-size: 13px;
      text-align: center;
      padding: 40px 20px;
    }

    .wgs-rank-exact .wgs-breadcrumb-seg { color: #a0ffa0; }
    .wgs-rank-exact .wgs-match { background: #1a3a00; color: #a0ffa0; }
  `;
  shadow.appendChild(styleEl);

  // Flash styles injected into the HOST page (not shadow) so they apply to page elements
  let lastFlashTarget = null;

  // ── BUILD UI ──────────────────────────────────────────────────────────────────
  // FAB
  const fab = document.createElement('button');
  fab.id = 'wgs-fab';
  fab.title = 'PageGrep (Alt+G)';
  fab.textContent = '🔍';
  shadow.appendChild(fab);

  // Spotlight overlay
  const overlay = document.createElement('div');
  overlay.id = 'wgs-overlay';
  overlay.innerHTML = `
    <div id="wgs-spotlight">
      <span id="wgs-search-icon">🔍</span>
      <input id="wgs-input" type="text" placeholder="search this page…" autocomplete="off" spellcheck="false"/>
      <span id="wgs-kbd">Alt+G</span>
    </div>
  `;
  shadow.appendChild(overlay);

  // Results panel
  const panel = document.createElement('div');
  panel.id = 'wgs-panel';
  panel.innerHTML = `
    <div id="wgs-panel-header">
      <span id="wgs-panel-title">Results</span>
      <button id="wgs-close">✕</button>
    </div>
    <div id="wgs-results"></div>
  `;
  shadow.appendChild(panel);

  // ── THEME SAMPLING ────────────────────────────────────────────────────────────
  function sampleSiteTheme() {
    const contentRoot = document.querySelector(CONTENT_SELECTOR) || document.body;
    const docStyle = getComputedStyle(document.documentElement);

    function cssVar(...names) {
      for (const name of names) {
        const val = docStyle.getPropertyValue(name).trim();
        if (val) return val;
      }
      return null;
    }
    function usable(c) {
      return c && c !== 'rgba(0, 0, 0, 0)' && c !== 'transparent';
    }

    const vpBg   = cssVar('--vp-c-bg', '--vp-c-bg-soft', '--c-bg');
    const vpText = cssVar('--vp-c-text-1', '--vp-c-text', '--c-text');
    const vpLink = cssVar('--vp-c-brand-1', '--vp-c-brand', '--c-brand');
    const vpFont = cssVar('--vp-font-family-base');
    const dsBg   = cssVar('--ifm-background-color', '--ifm-background-surface-color');
    const dsText = cssVar('--ifm-font-color-base');
    const dsLink = cssVar('--ifm-link-color', '--ifm-color-primary');
    const dsFont = cssVar('--ifm-font-size-base');
    const gbBg   = cssVar('--color-base', '--background');
    const gbText = cssVar('--color-text-default', '--text-default');
    const gbLink = cssVar('--color-link', '--link');
    const mkBg   = cssVar('--md-default-bg-color');
    const mkText = cssVar('--md-default-fg-color');
    const mkLink = cssVar('--md-accent-fg-color', '--md-primary-fg-color');

    function getBgFromEl(el) {
      let node = el;
      while (node && node !== document.documentElement) {
        const bg = getComputedStyle(node).backgroundColor;
        if (usable(bg)) return bg;
        node = node.parentElement;
      }
      return getComputedStyle(document.documentElement).backgroundColor;
    }
    function getLinkColorNear(el) {
      for (const a of (el ? el.querySelectorAll('a') : [])) {
        const c = getComputedStyle(a).color;
        if (usable(c)) return c;
      }
      let parent = el ? el.parentElement : null;
      while (parent && parent !== document.body) {
        for (const a of parent.querySelectorAll('a')) {
          if (a.closest('#pagegrep-host, nav, header, aside, [role="navigation"]')) continue;
          const c = getComputedStyle(a).color;
          if (usable(c)) return c;
        }
        parent = parent.parentElement;
      }
      return null;
    }

    const textEl   = contentRoot.querySelector('li, p');
    const elemBg   = getBgFromEl(contentRoot);
    const elemText = textEl ? getComputedStyle(textEl).color : null;
    const elemLink = getLinkColorNear(textEl || contentRoot);
    const elemFont = textEl ? getComputedStyle(textEl).fontSize : null;

    const finalBg   = vpBg || dsBg || gbBg || mkBg || (usable(elemBg) ? elemBg : null);
    const finalText = vpText || dsText || gbText || mkText || (usable(elemText) ? elemText : null);
    const finalLink = vpLink || dsLink || gbLink || mkLink || (usable(elemLink) ? elemLink : null);
    const finalFont = vpFont || dsFont || elemFont;

    // Apply to the shadow root's :host via the panel (vars cascade inside shadow)
    const root = shadow.querySelector('#wgs-panel');
    setTimeout(() => {
      if (finalBg)   { root.style.setProperty('--wgs-site-bg', finalBg); overlay.querySelector('#wgs-spotlight').style.setProperty('--wgs-site-bg', finalBg); }
      if (finalText) { root.style.setProperty('--wgs-site-text', finalText); overlay.querySelector('#wgs-input').style.color = finalText; }
      if (finalLink) root.style.setProperty('--wgs-site-link', finalLink);
      if (finalFont) root.style.setProperty('--wgs-site-fontsize', finalFont);
    }, 0);
  }

  sampleSiteTheme();
  setTimeout(sampleSiteTheme, 800);
  new MutationObserver(sampleSiteTheme).observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'data-theme', 'data-dark-mode'] });

  const input    = shadow.getElementById('wgs-input');
  const closeBtn = shadow.getElementById('wgs-close');
  const results  = shadow.getElementById('wgs-results');
  const titleEl  = shadow.getElementById('wgs-panel-title');



  // ── INDEX ─────────────────────────────────────────────────────────────────────
  // Each entry:
  //   text        – full visible text of the line element (whole <li>, <p>, etc.)
  //   searchText  – lowercased: all ancestor heading texts + line text (for matching)
  //   headings    – [{level, text, id, node}, …] outermost→innermost
  let index = null;

  const LINE_TAGS    = new Set(['LI','P','DT','DD','TD','TH','BLOCKQUOTE','FIGCAPTION']);
  const HEADING_TAGS = new Set(['H1','H2','H3','H4','H5','H6']);

  function headingLevel(tag) { return parseInt(tag[1], 10); }

  function buildIndex() {
    if (index) return;
    index = [];

    const root = document.querySelector(CONTENT_SELECTOR) || document.body;

    // We walk the DOM in tree order using querySelectorAll (DOM order guaranteed).
    // We maintain a heading stack as we encounter headings.
    // For LINE_TAGS we emit an index entry — but skip ones that only contain
    // other LINE_TAG descendants (avoid double-indexing outer <li> of nested lists).

    const headingStack = []; // [{level, text, id, node}]

    const allEl = root.querySelectorAll('*');
    for (const el of allEl) {
      const tag = el.tagName;

      // ── Update heading stack ──
      if (HEADING_TAGS.has(tag)) {
        const lv = headingLevel(tag);
        while (headingStack.length && headingStack[headingStack.length - 1].level >= lv) {
          headingStack.pop();
        }
        // Strip leading decorator symbols common on wiki sites (▷ ► ★ • etc.)
        const rawText = el.textContent.trim();
        const cleanText = rawText.replace(/^[\s\u00a0\u2000-\u27bf|>\/-]+/g, '').trim() || rawText;
        headingStack.push({ level: lv, text: cleanText, id: el.id || null, node: el });
        continue;
      }

      // ── Emit line entry ──
      if (!LINE_TAGS.has(tag)) continue;

      // Skip if this element's meaningful text is entirely inside nested line elements
      // (e.g. a bare <li> wrapping <ul> with no own text)
      let ownText = '';
      for (const child of el.childNodes) {
        if (child.nodeType === Node.TEXT_NODE) ownText += child.textContent;
      }
      // If the element contains a nested LINE_TAG and has no meaningful direct text, skip
      if (el.querySelector('li,p,dt,dd,td,th,blockquote,figcaption') && ownText.trim().length < 2) {
        continue;
      }

      const text = el.textContent.trim().replace(/\s+/g, ' ');
      if (text.length < 2) continue;

      // snapshot: one heading per level max, sorted ascending
      const seenLevels = new Map();
      for (const h of headingStack) seenLevels.set(h.level, h);
      const headings = [...seenLevels.values()].sort((a, b) => a.level - b.level).map(h => ({ ...h }));
      const headingText = headings.map(h => h.text).join(' ');
      // Also include href values so queries like "codeberg bypass" can match
      // link URLs even when the URL text isn't part of the visible line text
      const hrefText = Array.from(el.querySelectorAll('a[href]'))
        .map(a => a.getAttribute('href'))
        .join(' ');
      const searchText = (headingText + ' ' + text + ' ' + hrefText).toLowerCase();

      index.push({ text, searchText, headings, el });
    }
  }

  // ── SCORING ───────────────────────────────────────────────────────────────────
  // 3 – exact phrase in line text
  // 2 – exact phrase in headings text
  // Scoring rationale:
  //   Primary:   how many query words match as full words (0..N), normalised to 0..4
  //              so even one full-word match beats all-substring matches.
  //   Secondary: phrase/order/unordered tier (0..3), same as before.
  //   Final:     primary * 4 + secondary  →  range 0..19 (no overlap between bands)
  //
  // Example with query "ha ai":
  //   "AI Studio"            → ai=full, ha=partial → 1 full-word → primary=2 → beats
  //   "Chat - Decentralized" → ai=partial, ha=partial → 0 full-words → primary=0

  function countFullWords(searchL, words) {
    let count = 0;
    for (const w of words) {
      const escaped = w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
      const re = new RegExp('(?<![a-z0-9])' + escaped + '(?![a-z0-9])', 'i');
      if (re.test(searchL)) count++;
    }
    return count;
  }

  function score(entry, words, phrase) {
    const lineL   = entry.text.toLowerCase();
    const searchL = entry.searchText;

    // Primary: full-word match count, scaled to 0-4
    // (0 full words=0, 1..N-1 partial=2, all N=4 — gives clear bands)
    const fw = countFullWords(searchL, words);
    const primary = fw === 0 ? 0 : fw === words.length ? 4 : 2;

    // Secondary: phrase / order / unordered (0-3)
    let secondary;
    if (lineL.includes(phrase))        secondary = 3;
    else if (searchL.includes(phrase)) secondary = 2;
    else {
      let pos = 0, inOrder = true;
      for (const w of words) {
        const idx = searchL.indexOf(w, pos);
        if (idx === -1) { inOrder = false; break; }
        pos = idx + w.length;
      }
      secondary = inOrder ? 1 : 0;
    }

    return primary * 4 + secondary;
  }

  // ── HIGHLIGHT ─────────────────────────────────────────────────────────────────
  function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }

  function highlightText(text, words) {
    const safe = text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
    if (!words.length) return safe;
    const pat = new RegExp(`(${words.map(escapeRe).join('|')})`, 'gi');
    return safe.replace(pat, '<span class="wgs-match">$1</span>');
  }

  // ── SEARCH ────────────────────────────────────────────────────────────────────
  function doSearch() {
    const query = input.value.trim();
    if (!query) return;

    buildIndex();

    const words  = query.toLowerCase().split(/\s+/).filter(Boolean);
    const phrase = words.join(' ');

    let matches = [];
    for (const entry of index) {
      if (words.every(w => entry.searchText.includes(w))) {
        matches.push({ entry, sc: score(entry, words, phrase) });
      }
    }

    matches.sort((a, b) => b.sc - a.sc);
    const total = matches.length;
    matches = matches.slice(0, MAX_RESULTS);

    titleEl.textContent = total === 0
      ? `No results — "${query}"`
      : total > matches.length
        ? `top ${matches.length} of ${total} — "${query}"`
        : `${total} result${total > 1 ? 's' : ''} — "${query}"`;

    results.innerHTML = '';

    if (!matches.length) {
      results.innerHTML = '<div class="wgs-empty">No matching lines found.</div>';
      openPanel();
      return;
    }

    const frag = document.createDocumentFragment();

    for (const { entry, sc } of matches) {
      const div = document.createElement('div');
      div.className = 'wgs-result' + (sc >= 16 ? ' wgs-rank-exact' : '');

      // ── Breadcrumb ──
      const bc = document.createElement('span');
      bc.className = 'wgs-breadcrumb';

      const heads = entry.headings.length ? entry.headings : [{ text: '(top)', id: null, node: null }];
      heads.forEach((h, i) => {
        if (i > 0) {
          const sep = document.createElement('span');
          sep.className = 'wgs-breadcrumb-sep';
          sep.textContent = '/';
          bc.appendChild(sep);
        }
        const seg = document.createElement('a');
        seg.className = 'wgs-breadcrumb-seg';
        seg.href = h.id ? `#${h.id}` : '#';
        seg.textContent = h.text;
        if (h.node) {
          seg.addEventListener('click', (e) => {
            e.preventDefault();
            h.node.scrollIntoView({ behavior: 'smooth', block: 'start' });
            closePanel();
          });
        }
        bc.appendChild(seg);
      });

      // ── Line ──
      const lineDiv = document.createElement('div');
      lineDiv.className = 'wgs-line';

      // Clone the original element to preserve links, then highlight text nodes
      const lineWords = words.filter(w => entry.text.toLowerCase().includes(w));
      if (entry.el) {
        const clone = entry.el.cloneNode(true);
        // Strip leading decorator symbols/bullets from the clone's text nodes
        // Walk all text nodes and clean leading decorators from the first non-empty one
        (function stripDecorators(node) {
          const tw = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, null, false);
          let tn;
          while ((tn = tw.nextNode())) {
            const trimmed = tn.textContent.replace(/^[\s\u00a0\u25b6\u25b7\u25ba\u25b8\u25b9\u25bc\u25bd\u25c6\u25c7\u2022\u00b7\u002a\u002d\u25cf]+/g, '');
            if (tn.textContent.trim().length > 0) {
              tn.textContent = trimmed;
              break;
            }
          }
        })(clone);
        // Open all links in new tab and stop them from closing the panel accidentally
        for (const a of clone.querySelectorAll('a')) {
          a.target = '_blank';
          a.rel    = 'noopener noreferrer';
        }
        // Walk text nodes and wrap matched words in highlight spans
        if (lineWords.length) {
          const pat = new RegExp('(' + lineWords.map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + ')', 'gi');
          const textWalker = document.createTreeWalker(clone, NodeFilter.SHOW_TEXT, null, false);
          const textNodes = [];
          let tn = textWalker.nextNode();
          while (tn) { textNodes.push(tn); tn = textWalker.nextNode(); }
          for (const tn of textNodes) {
            if (!pat.test(tn.textContent)) continue;
            pat.lastIndex = 0;
            const frag2 = document.createDocumentFragment();
            let last = 0, m;
            while ((m = pat.exec(tn.textContent)) !== null) {
              if (m.index > last) frag2.appendChild(document.createTextNode(tn.textContent.slice(last, m.index)));
              const mark = document.createElement('span');
              mark.className = 'wgs-match';
              mark.textContent = m[1];
              frag2.appendChild(mark);
              last = m.index + m[1].length;
            }
            if (last < tn.textContent.length) frag2.appendChild(document.createTextNode(tn.textContent.slice(last)));
            tn.parentNode.replaceChild(frag2, tn);
          }
        }
        lineDiv.appendChild(clone);
      } else {
        // Fallback: plain text with highlights
        const span = document.createElement('span');
        span.innerHTML = highlightText(entry.text, lineWords);
        lineDiv.appendChild(span);
      }

      div.appendChild(bc);
      div.appendChild(lineDiv);

      // Click anywhere on the result row → navigate + flash
      div.style.cursor = 'pointer';
      div.addEventListener('click', (e) => {
        // Don't intercept breadcrumb link clicks (they have their own handler)
        if (e.target.closest('.wgs-breadcrumb-seg')) return;
        navigateToResult(entry);
      });

      frag.appendChild(div);
    }

    results.appendChild(frag);
    selectedIdx = -1;
    openPanel();
  }

  // ── PANEL ─────────────────────────────────────────────────────────────────────

  let flashTimeout = null;

  function navigateToResult(entry) {
    const target = entry.el;
    const headings = entry.headings;

    // Scroll directly to the matched element, centered in the viewport
    if (target) {
      target.scrollIntoView({ behavior: 'smooth', block: 'center' });
    }

    // Flash: directly set inline transition on the element
    if (target) {
      clearTimeout(flashTimeout);

      // Clear any previous flash on other elements
      if (lastFlashTarget && lastFlashTarget !== target) {
        lastFlashTarget.style.background = '';
        lastFlashTarget.style.boxShadow = '';
        lastFlashTarget.style.transition = '';
        lastFlashTarget.style.borderRadius = '';
      }
      lastFlashTarget = target;

      // Apply highlight immediately
      target.style.transition = 'none';
      target.style.background = 'rgba(255,220,50,.22)';
      target.style.boxShadow  = 'inset 3px 0 0 rgba(255,200,0,.9)';
      target.style.borderRadius = '3px';

      // After 21s start fading out over 9s
      flashTimeout = setTimeout(() => {
        target.style.transition = 'background 9s ease-out, box-shadow 9s ease-out';
        target.style.background = 'transparent';
        target.style.boxShadow  = 'none';
      }, 21000);

      // Clean up after full 30s
      setTimeout(() => {
        if (lastFlashTarget === target) {
          target.style.background = '';
          target.style.boxShadow  = '';
          target.style.transition = '';
          target.style.borderRadius = '';
          lastFlashTarget = null;
        }
      }, 31000);
    }
  }

  function openPanel()  { panel.classList.add('open'); }
  function closePanel() { panel.classList.remove('open'); }

  // ── SPOTLIGHT OPEN/CLOSE ──────────────────────────────────────────────────────
  function openSpotlight() {
    overlay.classList.add('open');
    fab.classList.add('active');
    requestAnimationFrame(() => input.focus());
  }
  function closeSpotlight() {
    overlay.classList.remove('open');
    fab.classList.remove('active');
  }

  // ── EVENTS ────────────────────────────────────────────────────────────────────
  let searchDebounce = null;
  function scheduleSearch() {
    clearTimeout(searchDebounce);
    searchDebounce = setTimeout(doSearch, 220);
  }

  // FAB click: reopen panel if results exist, else open spotlight
  fab.addEventListener('click', () => {
    if (overlay.classList.contains('open')) {
      closeSpotlight();
    } else if (!panel.classList.contains('open') && input.value.trim() && results.children.length) {
      openPanel();
      openSpotlight();
    } else {
      openSpotlight();
    }
  });

  // Clicking backdrop (not spotlight box) closes it
  overlay.addEventListener('click', (e) => {
    if (e.target === overlay) { closeSpotlight(); closePanel(); }
  });

  input.addEventListener('input', () => {
    if (input.value.trim()) scheduleSearch();
    else closePanel();
  });

  // Click input: reopen panel if results already exist
  input.addEventListener('click', () => {
    if (input.value.trim() && !panel.classList.contains('open') && results.children.length) {
      openPanel();
    }
  });

  let selectedIdx = -1;

  function getResultDivs() {
    return Array.from(results.querySelectorAll('.wgs-result'));
  }

  function selectResult(idx) {
    const divs = getResultDivs();
    if (!divs.length) return;
    // Clamp
    idx = Math.max(0, Math.min(divs.length - 1, idx));
    // Clear previous
    divs.forEach(d => d.style.outline = '');
    selectedIdx = idx;
    divs[idx].style.outline = '2px solid #7c83fd';
    divs[idx].scrollIntoView({ block: 'nearest' });
  }

  input.addEventListener('keydown', (e) => {
    if (e.key === 'Enter') {
      clearTimeout(searchDebounce);
      if (selectedIdx >= 0) {
        const divs = getResultDivs();
        if (divs[selectedIdx]) divs[selectedIdx].click();
      } else {
        doSearch();
      }
    }
    if (e.key === 'ArrowDown') {
      e.preventDefault();
      if (!panel.classList.contains('open')) return;
      selectResult(selectedIdx < 0 ? 0 : selectedIdx + 1);
    }
    if (e.key === 'ArrowUp') {
      e.preventDefault();
      if (!panel.classList.contains('open')) return;
      selectResult(selectedIdx <= 0 ? 0 : selectedIdx - 1);
    }
    if (e.key === 'Escape') {
      if (panel.classList.contains('open')) closePanel();
      else closeSpotlight();
    }
  });

  closeBtn.addEventListener('click', () => { closePanel(); });

  // Alt+G global hotkey
  document.addEventListener('keydown', (e) => {
    if (e.altKey && e.key === 'g') {
      e.preventDefault();
      if (overlay.classList.contains('open')) closeSpotlight();
      else openSpotlight();
    }
  });

  // Close panel when clicking outside shadow host on the page
  document.addEventListener('click', (e) => {
    if (panel.classList.contains('open') && !host.contains(e.target)) {
      closePanel();
    }
  });

  // ── FAB DRAG ──────────────────────────────────────────────────────────────────
  const FAB_STORE_KEY = 'pagegrep-fab-pos-' + location.hostname;

  function saveFabPos(x, y) {
    try { localStorage.setItem(FAB_STORE_KEY, JSON.stringify({ x, y })); } catch(_) {}
  }
  function loadFabPos() {
    try { return JSON.parse(localStorage.getItem(FAB_STORE_KEY)); } catch(_) { return null; }
  }
  function applyFabPos(x, y) {
    // Clamp to viewport
    const s = 42; // FAB size
    x = Math.max(0, Math.min(window.innerWidth  - s, x));
    y = Math.max(0, Math.min(window.innerHeight - s, y));
    fab.style.right  = 'auto';
    fab.style.bottom = 'auto';
    fab.style.left   = x + 'px';
    fab.style.top    = y + 'px';
  }

  // Restore saved position
  const savedPos = loadFabPos();
  if (savedPos) applyFabPos(savedPos.x, savedPos.y);

  let dragState = null;
  fab.addEventListener('pointerdown', (e) => {
    if (e.button !== 0) return;
    const rect = fab.getBoundingClientRect();
    dragState = {
      startX: e.clientX,
      startY: e.clientY,
      fabX: rect.left,
      fabY: rect.top,
      moved: false
    };
    fab.setPointerCapture(e.pointerId);
    e.preventDefault();
  });

  fab.addEventListener('pointermove', (e) => {
    if (!dragState) return;
    const dx = e.clientX - dragState.startX;
    const dy = e.clientY - dragState.startY;
    if (!dragState.moved && Math.hypot(dx, dy) < 4) return;
    dragState.moved = true;
    requestAnimationFrame(() => {
      if (!dragState) return;
      applyFabPos(dragState.fabX + (e.clientX - dragState.startX),
                  dragState.fabY + (e.clientY - dragState.startY));
    });
  });

  fab.addEventListener('pointerup', (e) => {
    if (!dragState) return;
    if (dragState.moved) {
      const rect = fab.getBoundingClientRect();
      saveFabPos(rect.left, rect.top);
    }
    dragState = null;
  });

})();