PageGrep

Grep-style complementary search engine for wiki/static sites

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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;
  });

})();