Greasy Fork is available in English.

GitHub Similar Repos

Full-featured Similar Repos panel for GitHub. Tabs, filters, sort, bookmarks, dismissals, settings, keyboard shortcut and API token support.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         GitHub Similar Repos
// @namespace    https://github.com/
// @version      3.1.0
// @description  Full-featured Similar Repos panel for GitHub. Tabs, filters, sort, bookmarks, dismissals, settings, keyboard shortcut and API token support.
// @author       a9s2w5
// @license      GNU GPLv3
// @match        https://github.com/*/*
// @exclude      https://github.com/*/*/**
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_listValues
// @grant        GM_xmlhttpRequest
// @grant        GM_openInTab
// @connect      libhunt.com
// @connect      api.github.com
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  // ════════════════════════════════════════════════════════════
  //  STORAGE KEYS
  // ════════════════════════════════════════════════════════════
  const KEY = {
    prefix:    'gsr3_',
    cache:     (o, r) => `gsr3_cache_${o}_${r}`,
    settings:  'gsr3_settings',
    dismissed: 'gsr3_dismissed',
    saved:     'gsr3_saved',
    visited:   'gsr3_visited',
    recent:    'gsr3_recent',
    ghStarred: 'gsr3_ghstarred',
  };

  // ════════════════════════════════════════════════════════════
  //  DEFAULTS
  // ════════════════════════════════════════════════════════════
  const DEFAULTS = {
    token:        '',
    fontSize:     'default',   // 'small' | 'default' | 'large'
    fontFace:     'system',    // 'system' | 'mono' | 'serif'
    shortcutOn:   true,
    shortcutKey:  'alt+s',
    panelOpen:    true,
    enabled:      true,        // global on/off toggle
    sortBy:       'relevance', // 'relevance' | 'stars' | 'activity'
    langFilter:   '',
  };

  const FONT_FACES = {
    system: `-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`,
    mono:   `"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace`,
    serif:  `Georgia, "Times New Roman", serif`,
  };
  const FONT_SIZES = { small: '11px', default: '13px', large: '15px' };
  const CACHE_TTL  = 6 * 60 * 60 * 1000;
  const PANEL_ID   = 'gsr-panel';
  const PANEL_MAX_H= '400px';

  // ════════════════════════════════════════════════════════════
  //  STATE
  // ════════════════════════════════════════════════════════════
  let settings   = {};
  let dismissed  = {};   // slug → true
  let saved      = {};   // slug → { slug, href, desc, stars, pushedAt, language }
  let visited    = {};   // slug → timestamp
  let recent     = {};   // slug → { slug, href, desc, stars, pushedAt, language, visitedAt }
  let ghStarred  = {};   // slug → true  (locally tracked GitHub stars)
  let activeTab  = 'similar';  // 'similar' | 'saved' | 'recent' | 'dismissed'
  let settingsOpen = false;
  let currentResult = null;
  let currentOwner  = '';
  let currentRepo   = '';

  function loadState() {
    settings  = Object.assign({}, DEFAULTS, safeGet(KEY.settings,  {}));
    dismissed = safeGet(KEY.dismissed, {});
    saved     = safeGet(KEY.saved,     {});
    visited   = safeGet(KEY.visited,   {});
    recent    = safeGet(KEY.recent,    {});
    ghStarred = safeGet(KEY.ghStarred, {});
  }

  function saveSettings()  { GM_setValue(KEY.settings,  JSON.stringify(settings));  }
  function saveDismissed() { GM_setValue(KEY.dismissed, JSON.stringify(dismissed)); }
  function saveSaved()     { GM_setValue(KEY.saved,     JSON.stringify(saved));     }
  function saveVisited()   { GM_setValue(KEY.visited,   JSON.stringify(visited));   }
  function saveRecent()    { GM_setValue(KEY.recent,    JSON.stringify(recent));    }
  function saveGhStarred() { GM_setValue(KEY.ghStarred, JSON.stringify(ghStarred)); }

  function safeGet(key, fallback) {
    try { const v = GM_getValue(key, null); return v ? JSON.parse(v) : fallback; }
    catch { return fallback; }
  }

  // ════════════════════════════════════════════════════════════
  //  HELPERS
  // ════════════════════════════════════════════════════════════
  function getRepoParts() {
    const m = location.pathname.match(/^\/([^/]+)\/([^/]+)\/?$/);
    return m ? { owner: m[1], repo: m[2] } : null;
  }

  function timeAgo(iso) {
    if (!iso) return null;
    const d = (Date.now() - new Date(iso)) / 86400000;
    if (d < 1/24)  return `${Math.floor(d*24*60)}m ago`;
    if (d < 1)     return `${Math.floor(d*24)}h ago`;
    if (d < 7)     return `${Math.floor(d)}d ago`;
    if (d < 30)    return `${Math.floor(d/7)}w ago`;
    if (d < 365)   return `${Math.floor(d/30)}mo ago`;
    return `${Math.floor(d/365)}y ago`;
  }

  function freshnessColor(iso) {
    if (!iso) return '#9B9A97';
    const d = (Date.now() - new Date(iso)) / 86400000;
    if (d < 7)   return '#4CAF50';
    if (d < 30)  return '#8BC34A';
    if (d < 90)  return '#FF9800';
    if (d < 365) return '#F44336';
    return '#9B9A97';
  }

  function formatStars(n) {
    if (n == null) return '';
    if (n >= 1000000) return `${(n/1000000).toFixed(1)}M`;
    if (n >= 1000)    return `${(n/1000).toFixed(1)}k`;
    return `${n}`;
  }

  function esc(s) {
    return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
  }

  function apiHeaders() {
    const h = { Accept: 'application/vnd.github.v3+json' };
    if (settings.token) h['Authorization'] = `token ${settings.token}`;
    return h;
  }

  function gmFetch(url, opts = {}) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET', url, timeout: 10000, ...opts,
        onload: r  => resolve(r),
        onerror: e => reject(e),
        ontimeout: () => reject(new Error('timeout')),
      });
    });
  }

  // Toggle a GitHub star via the API. Requires a token with public_repo scope.
  // Returns { ok: bool, nowStarred: bool }
  function toggleGhStar(slug) {
    const isStarred = !!ghStarred[slug];
    const method    = isStarred ? 'DELETE' : 'PUT';
    return new Promise(resolve => {
      if (!settings.token) { resolve({ ok: false, nowStarred: isStarred }); return; }
      GM_xmlhttpRequest({
        method,
        url: `https://api.github.com/user/starred/${slug}`,
        headers: {
          Authorization: `token ${settings.token}`,
          Accept: 'application/vnd.github.v3+json',
          'Content-Length': '0',
        },
        timeout: 8000,
        onload(r) {
          // 204 = success for both PUT and DELETE
          if (r.status === 204) {
            if (isStarred) delete ghStarred[slug];
            else ghStarred[slug] = true;
            saveGhStarred();
            resolve({ ok: true, nowStarred: !isStarred });
          } else {
            resolve({ ok: false, nowStarred: isStarred });
          }
        },
        onerror:   () => resolve({ ok: false, nowStarred: isStarred }),
        ontimeout: () => resolve({ ok: false, nowStarred: isStarred }),
      });
    });
  }

  // ════════════════════════════════════════════════════════════
  //  DATA FETCHING
  // ════════════════════════════════════════════════════════════
  async function fetchLibHunt(owner, repo) {
    const url = `https://www.libhunt.com/r/${repo}`;
    let resp;
    try { resp = await gmFetch(url); } catch { return null; }
    if (resp.status !== 200) return null;

    const doc  = new DOMParser().parseFromString(resp.responseText, 'text/html');
    const seen = new Set();
    const slugs = [];

    for (const a of doc.querySelectorAll('a[href]')) {
      if (a.closest('nav, header, footer')) continue;
      const href = a.getAttribute('href') || '';
      const m = href.match(/^https:\/\/github\.com\/([^/]+\/[^/]+)\/?$/);
      if (!m) continue;
      const slug = m[1];
      if (slug.toLowerCase() === `${owner}/${repo}`.toLowerCase()) continue;
      if (/^(github|topics|collections|trending)\//i.test(slug)) continue;
      if (seen.has(slug)) continue;
      seen.add(slug);
      slugs.push(slug);
    }

    return slugs.length ? { source: 'LibHunt', libhuntUrl: url, slugs } : null;
  }

  async function fetchGitHubTopics(owner, repo) {
    let r1;
    try {
      r1 = await gmFetch(`https://api.github.com/repos/${owner}/${repo}/topics`, {
        headers: Object.assign({ Accept: 'application/vnd.github.mercy-preview+json' }, settings.token ? { Authorization: `token ${settings.token}` } : {}),
      });
    } catch { return null; }
    let topics;
    try { ({ names: topics } = JSON.parse(r1.responseText)); } catch { return null; }
    if (!topics?.length) return null;

    const topic = [...topics].sort((a, b) => b.length - a.length)[0];
    let r2;
    try {
      r2 = await gmFetch(
        `https://api.github.com/search/repositories?q=topic:${encodeURIComponent(topic)}&sort=stars&per_page=30`,
        { headers: apiHeaders() }
      );
    } catch { return null; }
    let raw;
    try { ({ items: raw } = JSON.parse(r2.responseText)); } catch { return null; }

    const items = (raw || [])
      .filter(r => r.full_name.toLowerCase() !== `${owner}/${repo}`.toLowerCase())
      .map(r => ({
        slug: r.full_name, stars: r.stargazers_count, pushedAt: r.pushed_at,
        desc: (r.description || '').slice(0, 140), href: r.html_url, language: r.language,
        archived: r.archived,
      }));

    return items.length ? { source: `topic: ${topic}`, libhuntUrl: null, items } : null;
  }

  async function enrichSlugs(slugs) {
    const CONCURRENCY = 4;
    const results = [];
    let rateLimited = false;

    for (let i = 0; i < slugs.length; i += CONCURRENCY) {
      const batch = slugs.slice(i, i + CONCURRENCY);
      const settled = await Promise.allSettled(batch.map(slug =>
        gmFetch(`https://api.github.com/repos/${slug}`, { headers: apiHeaders() }).then(r => {
          const d = JSON.parse(r.responseText);
          if (d.message?.includes('rate limit')) { rateLimited = true; return null; }
          if (d.message) return null;
          return {
            slug: d.full_name, stars: d.stargazers_count, pushedAt: d.pushed_at,
            desc: (d.description || '').slice(0, 140), href: d.html_url,
            language: d.language, archived: d.archived,
          };
        })
      ));
      settled.forEach(s => { if (s.status === 'fulfilled' && s.value) results.push(s.value); });
    }

    return { items: results, rateLimited };
  }

  // ════════════════════════════════════════════════════════════
  //  SORTED / FILTERED VIEW
  // ════════════════════════════════════════════════════════════
  function applyFiltersAndSort(items) {
    let out = items.filter(it => !dismissed[it.slug]);

    if (settings.langFilter) {
      out = out.filter(it => (it.language || '').toLowerCase() === settings.langFilter.toLowerCase());
    }

    if (settings.sortBy === 'stars') {
      out = out.slice().sort((a, b) => (b.stars || 0) - (a.stars || 0));
    } else if (settings.sortBy === 'activity') {
      out = out.slice().sort((a, b) => {
        const ta = a.pushedAt ? new Date(a.pushedAt).getTime() : 0;
        const tb = b.pushedAt ? new Date(b.pushedAt).getTime() : 0;
        return tb - ta;
      });
    }
    return out;
  }

  function uniqueLangs(items) {
    const counts = {};
    items.forEach(it => { if (it.language) counts[it.language] = (counts[it.language]||0)+1; });
    return Object.entries(counts).sort((a,b) => b[1]-a[1]).map(e => e[0]);
  }

  // ════════════════════════════════════════════════════════════
  //  CSS
  // ════════════════════════════════════════════════════════════
  function injectStyles() {
    const existing = document.getElementById('gsr-styles');
    if (existing) existing.remove();

    const ff   = FONT_FACES[settings.fontFace] || FONT_FACES.system;
    const fz   = FONT_SIZES[settings.fontSize] || FONT_SIZES.default;
    const fzSm = settings.fontSize === 'large' ? '13px' : settings.fontSize === 'small' ? '10px' : '11.5px';
    const fzXs = settings.fontSize === 'large' ? '12px' : settings.fontSize === 'small' ? '9.5px'  : '11px';

    const s = document.createElement('style');
    s.id = 'gsr-styles';
    s.textContent = `
      /* ── Root ── */
      #gsr-panel {
        font-family: ${ff};
        font-size: ${fz};
        line-height: 1.5;
        margin-bottom: 16px;
      }

      /* ── Header bar ── */
      #gsr-panel .gsr-header {
        display: flex;
        align-items: center;
        justify-content: space-between;
        padding: 12px 0 10px;
        border-bottom: 1px solid var(--color-border-muted,#d0d7de);
        user-select: none;
        gap: 6px;
      }
      #gsr-panel .gsr-title-row {
        display: flex;
        align-items: center;
        gap: 6px;
        cursor: pointer;
        flex: 1;
        min-width: 0;
      }
      /* Notion-style pill wrapping icon + title */
      #gsr-panel .gsr-title-pill {
        display: inline-flex;
        align-items: center;
        gap: 6px;
        padding: 3px 8px 3px 6px;
        border-radius: 6px;
        background: var(--color-accent-subtle, rgba(9,105,218,0.1));
        border: 1px solid var(--color-accent-emphasis, rgba(9,105,218,0.2));
        min-width: 0;
        overflow: hidden;
      }
      #gsr-panel .gsr-title-icon {
        width: 13px; height: 13px;
        flex-shrink: 0;
        color: var(--color-accent-fg, #0969da);
      }
      #gsr-panel .gsr-title-text {
        font-size: ${fz};
        font-weight: 700;
        color: var(--color-accent-fg, #0969da);
        letter-spacing: -0.01em;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
      }
      #gsr-panel .gsr-chevron {
        font-size: 9px;
        color: var(--color-fg-subtle,#848d97);
        transition: transform 0.18s ease;
        flex-shrink: 0;
      }
      #gsr-panel .gsr-header-actions {
        display: flex;
        align-items: center;
        gap: 5px;
        flex-shrink: 0;
      }
      #gsr-panel .gsr-icon-btn {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        width: 22px; height: 22px;
        border-radius: 5px;
        border: none;
        background: transparent;
        cursor: pointer;
        color: var(--color-fg-subtle,#848d97);
        transition: background 0.12s, color 0.12s;
        padding: 0;
        font-size: 13px;
        line-height: 1;
      }
      #gsr-panel .gsr-icon-btn:hover {
        background: var(--color-neutral-subtle,rgba(175,184,193,0.15));
        color: var(--color-fg-default,#1f2328);
      }
      #gsr-panel .gsr-icon-btn.active {
        background: var(--color-neutral-subtle,rgba(175,184,193,0.2));
        color: var(--color-fg-default,#1f2328);
      }

      /* ── Enabled/disabled pill toggle ── */
      #gsr-panel .gsr-enable-pill {
        display: inline-flex;
        align-items: center;
        gap: 4px;
        padding: 2px 8px 2px 5px;
        border-radius: 20px;
        border: 1px solid;
        cursor: pointer;
        font-size: 10.5px;
        font-weight: 600;
        letter-spacing: 0.02em;
        transition: background 0.15s, border-color 0.15s, color 0.15s;
        user-select: none;
        white-space: nowrap;
        font-family: inherit;
        line-height: 1;
      }
      #gsr-panel .gsr-enable-pill.on {
        background: rgba(74,185,88,0.12);
        border-color: rgba(74,185,88,0.35);
        color: #2da44e;
      }
      #gsr-panel .gsr-enable-pill.off {
        background: rgba(207,34,46,0.08);
        border-color: rgba(207,34,46,0.25);
        color: #cf222e;
      }
      #gsr-panel .gsr-enable-pill:hover.on {
        background: rgba(74,185,88,0.2);
        border-color: rgba(74,185,88,0.5);
      }
      #gsr-panel .gsr-enable-pill:hover.off {
        background: rgba(207,34,46,0.14);
        border-color: rgba(207,34,46,0.4);
      }
      #gsr-panel .gsr-enable-dot {
        width: 6px; height: 6px;
        border-radius: 50%;
        flex-shrink: 0;
        transition: background 0.15s;
      }
      #gsr-panel .gsr-enable-pill.on  .gsr-enable-dot { background: #2da44e; }
      #gsr-panel .gsr-enable-pill.off .gsr-enable-dot { background: #cf222e; }

      /* Disabled state: body is dimmed / locked */
      #gsr-panel.gsr-disabled .gsr-body {
        opacity: 0.45;
        pointer-events: none;
        user-select: none;
      }

      /* ── Body ── */
      #gsr-panel .gsr-body { padding-top: 10px; }

      /* ── Tabs ── */
      #gsr-panel .gsr-tabs {
        display: grid;
        grid-template-columns: repeat(4, 1fr);
        gap: 2px;
        margin-bottom: 10px;
        background: var(--color-neutral-subtle,rgba(175,184,193,0.1));
        border-radius: 8px;
        padding: 3px;
      }
      #gsr-panel .gsr-tab {
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        gap: 2px;
        padding: 6px 2px;
        border-radius: 6px;
        font-size: ${fzXs};
        font-weight: 500;
        cursor: pointer;
        color: var(--color-fg-muted,#656d76);
        transition: background 0.14s, color 0.14s;
        user-select: none;
        min-width: 0;
        overflow: hidden;
      }
      #gsr-panel .gsr-tab-label {
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
        width: 100%;
        text-align: center;
        line-height: 1.2;
      }
      #gsr-panel .gsr-tab.active {
        background: var(--color-canvas-default,#fff);
        color: var(--color-fg-default,#1f2328);
        box-shadow: 0 1px 3px rgba(0,0,0,0.08);
      }
      #gsr-panel .gsr-tab-badge {
        display: inline-block;
        background: var(--color-neutral-subtle,rgba(175,184,193,0.3));
        border-radius: 20px;
        padding: 0 5px;
        font-size: ${fzXs};
        font-weight: 700;
        min-width: 18px;
        text-align: center;
        line-height: 1.6;
        letter-spacing: 0;
      }
      #gsr-panel .gsr-tab.active .gsr-tab-badge {
        background: var(--color-accent-subtle,rgba(9,105,218,0.12));
        color: var(--color-accent-fg,#0969da);
      }

      /* ── Controls bar (sort + lang filter) ── */
      #gsr-panel .gsr-controls {
        display: flex;
        align-items: center;
        gap: 6px;
        margin-bottom: 8px;
        flex-wrap: wrap;
      }
      #gsr-panel .gsr-select {
        flex: 1;
        min-width: 0;
        font-family: inherit;
        font-size: ${fzXs};
        padding: 3px 7px;
        border-radius: 6px;
        border: 1px solid var(--color-border-default,#d0d7de);
        background: var(--color-canvas-default,#fff);
        color: var(--color-fg-default,#1f2328);
        cursor: pointer;
        outline: none;
        appearance: none;
        -webkit-appearance: none;
        background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23848d97'/%3E%3C/svg%3E");
        background-repeat: no-repeat;
        background-position: right 7px center;
        padding-right: 22px;
      }
      #gsr-panel .gsr-select:focus {
        border-color: var(--color-accent-fg,#0969da);
        box-shadow: 0 0 0 2px rgba(9,105,218,0.12);
      }
      #gsr-panel .gsr-lang-chips {
        display: flex;
        gap: 4px;
        flex-wrap: wrap;
        margin-bottom: 8px;
      }
      #gsr-panel .gsr-chip {
        font-size: ${fzXs};
        padding: 2px 8px;
        border-radius: 20px;
        border: 1px solid var(--color-border-default,#d0d7de);
        background: transparent;
        color: var(--color-fg-muted,#656d76);
        cursor: pointer;
        transition: all 0.12s;
        font-family: inherit;
        white-space: nowrap;
      }
      #gsr-panel .gsr-chip:hover {
        border-color: var(--color-accent-fg,#0969da);
        color: var(--color-accent-fg,#0969da);
      }
      #gsr-panel .gsr-chip.active {
        background: var(--color-accent-subtle,rgba(9,105,218,0.1));
        border-color: var(--color-accent-fg,#0969da);
        color: var(--color-accent-fg,#0969da);
        font-weight: 600;
      }

      /* ── Scrollable list ── */
      #gsr-panel .gsr-scroll {
        overflow-y: auto;
        max-height: ${PANEL_MAX_H};
        scrollbar-width: thin;
        scrollbar-color: var(--color-border-default,#d0d7de) transparent;
        margin-right: -4px;
        padding-right: 2px;
      }
      #gsr-panel .gsr-scroll::-webkit-scrollbar        { width: 3px; }
      #gsr-panel .gsr-scroll::-webkit-scrollbar-thumb  { background: var(--color-border-default,#d0d7de); border-radius: 4px; }
      #gsr-panel .gsr-scroll::-webkit-scrollbar-track  { background: transparent; }

      /* ── Repo card ── */
      #gsr-panel .gsr-item {
        display: block;
        padding: 8px 9px;
        border-radius: 8px;
        margin-bottom: 2px;
        text-decoration: none;
        color: inherit;
        transition: background 0.1s;
        position: relative;
      }
      #gsr-panel .gsr-item:hover { background: var(--color-neutral-subtle,rgba(175,184,193,0.12)); }
      #gsr-panel .gsr-item.visited .gsr-repo-name { opacity: 0.55; }
      #gsr-panel .gsr-item.archived { opacity: 0.6; }

      #gsr-panel .gsr-item-top {
        display: flex;
        align-items: center;
        gap: 5px;
      }
      #gsr-panel .gsr-repo-name {
        font-size: ${fz};
        font-weight: 500;
        color: var(--color-accent-fg,#0969da);
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
        flex: 1;
        min-width: 0;
      }
      #gsr-panel .gsr-badges {
        display: flex;
        align-items: center;
        gap: 4px;
        flex-shrink: 0;
      }
      #gsr-panel .gsr-stars {
        display: inline-flex;
        align-items: center;
        gap: 2px;
        font-size: ${fzXs};
        color: var(--color-fg-muted,#656d76);
        font-variant-numeric: tabular-nums;
      }
      #gsr-panel .gsr-activity {
        display: inline-flex;
        align-items: center;
        gap: 2px;
        font-size: ${fzXs};
        font-weight: 500;
        padding: 1px 5px;
        border-radius: 20px;
        border: 1px solid;
        white-space: nowrap;
        line-height: 1.6;
      }
      #gsr-panel .gsr-dot {
        width: 5px; height: 5px;
        border-radius: 50%;
        flex-shrink: 0;
        display: inline-block;
      }
      #gsr-panel .gsr-archived-badge {
        font-size: ${fzXs};
        padding: 1px 5px;
        border-radius: 4px;
        background: var(--color-neutral-subtle,rgba(175,184,193,0.2));
        color: var(--color-fg-subtle,#848d97);
        border: 1px solid var(--color-border-muted,#d0d7de);
        white-space: nowrap;
      }
      #gsr-panel .gsr-desc {
        font-size: ${fzSm};
        color: var(--color-fg-muted,#656d76);
        margin-top: 3px;
        line-height: 1.4;
        display: -webkit-box;
        -webkit-line-clamp: 2;
        -webkit-box-orient: vertical;
        overflow: hidden;
      }
      #gsr-panel .gsr-item-meta {
        display: flex;
        align-items: center;
        gap: 8px;
        margin-top: 3px;
      }
      #gsr-panel .gsr-lang {
        font-size: ${fzXs};
        color: var(--color-fg-subtle,#848d97);
      }
      #gsr-panel .gsr-lang-chip {
        display: inline-block;
        font-size: ${fzXs};
        padding: 1px 7px;
        border-radius: 20px;
        border: 1px solid var(--color-border-default,#d0d7de);
        color: var(--color-fg-muted,#656d76);
        background: transparent;
        line-height: 1.6;
      }

      /* Action buttons that appear on hover */
      #gsr-panel .gsr-item-actions {
        position: absolute;
        right: 6px;
        top: 50%;
        transform: translateY(-50%);
        display: none;
        align-items: center;
        gap: 3px;
        background: var(--color-canvas-default,#fff);
        border-radius: 6px;
        padding: 2px;
        box-shadow: 0 1px 4px rgba(0,0,0,0.12);
        z-index: 1;
      }
      #gsr-panel .gsr-item:hover .gsr-item-actions { display: flex; }
      #gsr-panel .gsr-action-btn {
        width: 22px; height: 22px;
        border-radius: 4px;
        border: none;
        background: transparent;
        cursor: pointer;
        display: inline-flex;
        align-items: center;
        justify-content: center;
        font-size: 13px;
        color: var(--color-fg-muted,#656d76);
        transition: background 0.1s, color 0.1s;
        padding: 0;
        line-height: 1;
      }
      #gsr-panel .gsr-action-btn:hover {
        background: var(--color-neutral-subtle,rgba(175,184,193,0.2));
        color: var(--color-fg-default,#1f2328);
      }
      #gsr-panel .gsr-action-btn.is-saved { color: var(--color-accent-fg,#0969da); }
      #gsr-panel .gsr-action-btn.gh-star-btn { color: var(--color-fg-muted,#656d76); }
      #gsr-panel .gsr-action-btn.gh-star-btn.starred { color: #e3b341; }
      #gsr-panel .gsr-action-btn.gh-star-btn:hover { color: #e3b341; }
      #gsr-panel .gsr-action-btn.gh-star-btn.no-token { opacity: 0.4; cursor: default; }
      #gsr-panel .gsr-action-btn.gh-star-btn.no-token:hover { color: var(--color-fg-muted,#656d76); background: transparent; }

      /* ── Footer ── */
      #gsr-panel .gsr-footer {
        margin-top: 8px;
        padding-top: 8px;
        border-top: 1px solid var(--color-border-muted,#d0d7de);
        font-size: ${fzXs};
        color: var(--color-fg-subtle,#848d97);
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 6px;
        flex-wrap: wrap;
      }
      #gsr-panel .gsr-footer a {
        color: var(--color-fg-subtle,#848d97);
        text-decoration: none;
      }
      #gsr-panel .gsr-footer a:hover { text-decoration: underline; }
      #gsr-panel .gsr-footer-actions {
        display: flex;
        gap: 8px;
        align-items: center;
      }
      #gsr-panel .gsr-footer-btn {
        background: none; border: none; padding: 0;
        font-size: ${fzXs};
        color: var(--color-fg-subtle,#848d97);
        cursor: pointer;
        font-family: inherit;
        text-decoration: none;
      }
      #gsr-panel .gsr-footer-btn:hover { color: var(--color-fg-default,#1f2328); text-decoration: underline; }

      /* ── Rate limit banner ── */
      #gsr-panel .gsr-ratelimit {
        font-size: ${fzXs};
        color: #9a6700;
        background: #fff8c5;
        border: 1px solid #e3b341;
        border-radius: 6px;
        padding: 6px 10px;
        margin-bottom: 8px;
        line-height: 1.4;
      }
      #gsr-panel .gsr-ratelimit a { color: #9a6700; }

      /* ── Loading / empty state ── */
      #gsr-panel .gsr-loading {
        font-size: ${fzSm};
        color: var(--color-fg-muted,#656d76);
        padding: 6px 2px 10px;
        display: flex;
        align-items: center;
        gap: 8px;
      }
      #gsr-panel .gsr-spinner {
        width: 12px; height: 12px;
        border: 2px solid var(--color-border-default,#d0d7de);
        border-top-color: var(--color-accent-fg,#0969da);
        border-radius: 50%;
        animation: gsr-spin 0.7s linear infinite;
        flex-shrink: 0;
      }
      @keyframes gsr-spin { to { transform: rotate(360deg); } }

      /* ── Settings panel ── */
      #gsr-settings {
        position: fixed;
        z-index: 99999;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
        width: 340px;
        max-width: calc(100vw - 32px);
        max-height: calc(100vh - 48px);
        overflow-y: auto;
        background: var(--color-canvas-default,#fff);
        border: 1px solid var(--color-border-default,#d0d7de);
        border-radius: 12px;
        box-shadow: 0 8px 32px rgba(0,0,0,0.15), 0 2px 8px rgba(0,0,0,0.08);
        padding: 20px;
        font-family: ${ff};
        font-size: 13px;
        color: var(--color-fg-default,#1f2328);
      }
      #gsr-settings-backdrop {
        position: fixed;
        inset: 0;
        z-index: 99998;
        background: rgba(0,0,0,0.3);
        backdrop-filter: blur(2px);
      }
      #gsr-settings .gsr-s-header {
        display: flex;
        align-items: center;
        justify-content: space-between;
        margin-bottom: 18px;
      }
      #gsr-settings .gsr-s-title {
        font-size: 15px;
        font-weight: 700;
        letter-spacing: -0.01em;
        color: var(--color-fg-default,#1f2328);
      }
      #gsr-settings .gsr-s-close {
        width: 28px; height: 28px;
        border-radius: 6px;
        border: none;
        background: transparent;
        cursor: pointer;
        color: var(--color-fg-subtle,#848d97);
        font-size: 16px;
        display: inline-flex;
        align-items: center;
        justify-content: center;
        transition: background 0.1s;
      }
      #gsr-settings .gsr-s-close:hover {
        background: var(--color-neutral-subtle,rgba(175,184,193,0.2));
        color: var(--color-fg-default,#1f2328);
      }
      #gsr-settings .gsr-s-section {
        margin-bottom: 18px;
        padding-bottom: 18px;
        border-bottom: 1px solid var(--color-border-muted,#d0d7de);
      }
      #gsr-settings .gsr-s-section:last-child { border-bottom: none; margin-bottom: 0; padding-bottom: 0; }
      #gsr-settings .gsr-s-label {
        font-size: 11px;
        font-weight: 700;
        text-transform: uppercase;
        letter-spacing: 0.07em;
        color: var(--color-fg-subtle,#848d97);
        margin-bottom: 10px;
      }
      #gsr-settings .gsr-s-row {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 10px;
        margin-bottom: 10px;
      }
      #gsr-settings .gsr-s-row:last-child { margin-bottom: 0; }
      #gsr-settings .gsr-s-row-label {
        font-size: 13px;
        color: var(--color-fg-default,#1f2328);
        flex: 1;
      }
      #gsr-settings .gsr-s-row-sub {
        font-size: 11px;
        color: var(--color-fg-subtle,#848d97);
        margin-top: 1px;
      }
      #gsr-settings .gsr-s-select {
        font-family: inherit;
        font-size: 12px;
        padding: 4px 28px 4px 8px;
        border-radius: 6px;
        border: 1px solid var(--color-border-default,#d0d7de);
        background: var(--color-canvas-default,#fff);
        color: var(--color-fg-default,#1f2328);
        cursor: pointer;
        outline: none;
        appearance: none;
        -webkit-appearance: none;
        background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23848d97'/%3E%3C/svg%3E");
        background-repeat: no-repeat;
        background-position: right 7px center;
        min-width: 110px;
      }

      /* Toggle switch */
      #gsr-settings .gsr-toggle-wrap {
        display: flex;
        align-items: center;
        gap: 8px;
      }
      #gsr-settings .gsr-toggle {
        position: relative;
        width: 34px; height: 18px;
        flex-shrink: 0;
        cursor: pointer;
      }
      #gsr-settings .gsr-toggle input { opacity: 0; width: 0; height: 0; position: absolute; }
      #gsr-settings .gsr-toggle-track {
        position: absolute;
        inset: 0;
        border-radius: 18px;
        background: var(--color-neutral-muted,rgba(175,184,193,0.4));
        transition: background 0.18s;
      }
      #gsr-settings .gsr-toggle input:checked + .gsr-toggle-track {
        background: var(--color-accent-fg,#0969da);
      }
      #gsr-settings .gsr-toggle-thumb {
        position: absolute;
        top: 2px; left: 2px;
        width: 14px; height: 14px;
        border-radius: 50%;
        background: #fff;
        transition: transform 0.18s;
        box-shadow: 0 1px 3px rgba(0,0,0,0.2);
        pointer-events: none;
      }
      #gsr-settings .gsr-toggle input:checked ~ .gsr-toggle-thumb { transform: translateX(16px); }

      /* Shortcut key input */
      #gsr-settings .gsr-key-input {
        font-family: ${FONT_FACES.mono};
        font-size: 12px;
        padding: 4px 8px;
        border-radius: 6px;
        border: 1px solid var(--color-border-default,#d0d7de);
        background: var(--color-canvas-default,#fff);
        color: var(--color-fg-default,#1f2328);
        width: 90px;
        outline: none;
      }
      #gsr-settings .gsr-key-input:focus {
        border-color: var(--color-accent-fg,#0969da);
        box-shadow: 0 0 0 2px rgba(9,105,218,0.12);
      }

      /* Token input */
      #gsr-settings .gsr-token-wrap {
        display: flex;
        flex-direction: column;
        gap: 6px;
      }
      #gsr-settings .gsr-token-input {
        font-family: ${FONT_FACES.mono};
        font-size: 12px;
        padding: 7px 10px;
        border-radius: 7px;
        border: 1px solid var(--color-border-default,#d0d7de);
        background: var(--color-canvas-default,#fff);
        color: var(--color-fg-default,#1f2328);
        width: 100%;
        box-sizing: border-box;
        outline: none;
      }
      #gsr-settings .gsr-token-input:focus {
        border-color: var(--color-accent-fg,#0969da);
        box-shadow: 0 0 0 2px rgba(9,105,218,0.12);
      }
      #gsr-settings .gsr-token-hint {
        font-size: 11px;
        color: var(--color-fg-subtle,#848d97);
        line-height: 1.4;
      }
      #gsr-settings .gsr-token-hint a { color: var(--color-accent-fg,#0969da); text-decoration: none; }
      #gsr-settings .gsr-token-hint a:hover { text-decoration: underline; }

      /* Slider */
      #gsr-settings .gsr-slider-row {
        display: flex;
        align-items: center;
        gap: 10px;
      }
      #gsr-settings .gsr-slider-label {
        font-size: 11px;
        color: var(--color-fg-muted,#656d76);
        white-space: nowrap;
      }
      #gsr-settings input[type=range].gsr-slider {
        flex: 1;
        -webkit-appearance: none;
        appearance: none;
        height: 3px;
        border-radius: 3px;
        background: var(--color-border-default,#d0d7de);
        outline: none;
        cursor: pointer;
      }
      #gsr-settings input[type=range].gsr-slider::-webkit-slider-thumb {
        -webkit-appearance: none;
        width: 14px; height: 14px;
        border-radius: 50%;
        background: var(--color-accent-fg,#0969da);
        box-shadow: 0 1px 3px rgba(0,0,0,0.2);
      }

      /* Action buttons in settings */
      #gsr-settings .gsr-s-btn {
        display: inline-flex;
        align-items: center;
        gap: 5px;
        padding: 6px 12px;
        border-radius: 7px;
        font-family: inherit;
        font-size: 12px;
        font-weight: 500;
        cursor: pointer;
        border: 1px solid var(--color-border-default,#d0d7de);
        background: var(--color-canvas-subtle,#f6f8fa);
        color: var(--color-fg-default,#1f2328);
        transition: background 0.1s;
      }
      #gsr-settings .gsr-s-btn:hover { background: var(--color-neutral-subtle,rgba(175,184,193,0.2)); }
      #gsr-settings .gsr-s-btn.danger {
        color: #cf222e;
        border-color: rgba(207,34,46,0.3);
      }
      #gsr-settings .gsr-s-btn.danger:hover { background: rgba(207,34,46,0.06); }
      #gsr-settings .gsr-s-btn-row {
        display: flex;
        gap: 8px;
        flex-wrap: wrap;
      }
    `;
    document.head.appendChild(s);
  }

  // ════════════════════════════════════════════════════════════
  //  SETTINGS MODAL
  // ════════════════════════════════════════════════════════════
  function openSettings() {
    if (document.getElementById('gsr-settings')) return;

    const backdrop = document.createElement('div');
    backdrop.id = 'gsr-settings-backdrop';
    backdrop.onclick = closeSettings;
    document.body.appendChild(backdrop);

    const modal = document.createElement('div');
    modal.id = 'gsr-settings';

    const fontSizeVal = { small: 0, default: 1, large: 2 }[settings.fontSize] ?? 1;

    modal.innerHTML = `
      <div class="gsr-s-header">
        <span class="gsr-s-title">⚙ Settings</span>
        <button class="gsr-s-close" id="gsr-s-close">✕</button>
      </div>

      <!-- Typography -->
      <div class="gsr-s-section">
        <div class="gsr-s-label">Typography</div>

        <div class="gsr-s-row">
          <div>
            <div class="gsr-s-row-label">Text size</div>
          </div>
          <div class="gsr-slider-row" style="width:160px">
            <span class="gsr-slider-label">A</span>
            <input type="range" class="gsr-slider" id="gsr-s-fontsize" min="0" max="2" step="1" value="${fontSizeVal}">
            <span class="gsr-slider-label" style="font-size:15px">A</span>
          </div>
        </div>

        <div class="gsr-s-row">
          <div class="gsr-s-row-label">Font style</div>
          <select class="gsr-s-select" id="gsr-s-fontface">
            <option value="system" ${settings.fontFace==='system'?'selected':''}>System UI</option>
            <option value="mono"   ${settings.fontFace==='mono'  ?'selected':''}>Monospace</option>
            <option value="serif"  ${settings.fontFace==='serif' ?'selected':''}>Serif</option>
          </select>
        </div>
      </div>

      <!-- Keyboard shortcut -->
      <div class="gsr-s-section">
        <div class="gsr-s-label">Keyboard Shortcut</div>
        <div class="gsr-s-row">
          <div>
            <div class="gsr-s-row-label">Enable shortcut</div>
            <div class="gsr-s-row-sub">Toggle panel open/closed</div>
          </div>
          <label class="gsr-toggle">
            <input type="checkbox" id="gsr-s-shortcuton" ${settings.shortcutOn?'checked':''}>
            <span class="gsr-toggle-track"></span>
            <span class="gsr-toggle-thumb"></span>
          </label>
        </div>
        <div class="gsr-s-row">
          <div class="gsr-s-row-label">Shortcut key</div>
          <input class="gsr-key-input" id="gsr-s-shortcutkey"
                 placeholder="e.g. alt+s"
                 value="${esc(settings.shortcutKey)}"
                 title="Format: modifier+key, e.g. alt+s, ctrl+shift+r">
        </div>
      </div>

      <!-- GitHub Token -->
      <div class="gsr-s-section">
        <div class="gsr-s-label">GitHub API Token</div>
        <div class="gsr-token-wrap">
          <input class="gsr-token-input" type="password" id="gsr-s-token"
                 placeholder="ghp_xxxxxxxxxxxxxxxxxxxx"
                 value="${esc(settings.token)}"
                 autocomplete="off" spellcheck="false">
          <div class="gsr-token-hint">
            Increases the API rate limit from 60 to 5,000 req/hr, and enables starring repos directly from the panel.<br>
            <a href="https://github.com/settings/tokens/new?description=GitHub+Similar+Repos&scopes=public_repo" target="_blank" rel="noopener">
              Generate a token ↗
            </a>
            — enable the <code>public_repo</code> scope to allow starring.
          </div>
        </div>
      </div>

      <!-- Cache & Data -->
      <div class="gsr-s-section">
        <div class="gsr-s-label">Cache & Data</div>
        <div class="gsr-s-btn-row">
          <button class="gsr-s-btn" id="gsr-s-clearcache">🗑 Clear results cache</button>
          <button class="gsr-s-btn danger" id="gsr-s-clearall">⚠ Reset everything</button>
        </div>
      </div>
    `;

    document.body.appendChild(modal);

    // Wire up events
    modal.querySelector('#gsr-s-close').onclick = closeSettings;

    // Font size slider
    const sizeLabels = ['small','default','large'];
    modal.querySelector('#gsr-s-fontsize').oninput = e => {
      settings.fontSize = sizeLabels[+e.target.value];
      saveSettings(); injectStyles();
    };

    // Font face
    modal.querySelector('#gsr-s-fontface').onchange = e => {
      settings.fontFace = e.target.value;
      saveSettings(); injectStyles();
    };

    // Shortcut toggle
    modal.querySelector('#gsr-s-shortcuton').onchange = e => {
      settings.shortcutOn = e.target.checked;
      saveSettings(); rebindShortcut();
    };

    // Shortcut key
    modal.querySelector('#gsr-s-shortcutkey').onchange = e => {
      settings.shortcutKey = e.target.value.trim().toLowerCase();
      saveSettings(); rebindShortcut();
    };

    // Token
    modal.querySelector('#gsr-s-token').onchange = e => {
      settings.token = e.target.value.trim();
      saveSettings();
    };

    // Clear cache
    modal.querySelector('#gsr-s-clearcache').onclick = () => {
      const all = GM_listValues();
      all.filter(k => k.startsWith('gsr3_cache_')).forEach(k => GM_deleteValue(k));
      closeSettings();
      // Reload current panel
      const p = document.getElementById(PANEL_ID);
      if (p) p.remove();
      document.getElementById('gsr-styles')?.remove();
      run();
    };

    // Reset everything
    modal.querySelector('#gsr-s-clearall').onclick = () => {
      if (!confirm('Reset all Similar Repos data including settings, bookmarks, and dismissals?')) return;
      GM_listValues().filter(k => k.startsWith('gsr3_')).forEach(k => GM_deleteValue(k));
      loadState();
      recent = {};
      ghStarred = {};
      closeSettings();
      const p = document.getElementById(PANEL_ID);
      if (p) p.remove();
      document.getElementById('gsr-styles')?.remove();
      run();
    };
  }

  function closeSettings() {
    document.getElementById('gsr-settings')?.remove();
    document.getElementById('gsr-settings-backdrop')?.remove();
  }

  // ════════════════════════════════════════════════════════════
  //  KEYBOARD SHORTCUT
  // ════════════════════════════════════════════════════════════
  let _shortcutHandler = null;

  function parseShortcut(str) {
    const parts = str.toLowerCase().split('+').map(s => s.trim());
    const key = parts.pop();
    return {
      alt:   parts.includes('alt'),
      ctrl:  parts.includes('ctrl') || parts.includes('control'),
      shift: parts.includes('shift'),
      meta:  parts.includes('meta') || parts.includes('cmd'),
      key,
    };
  }

  function rebindShortcut() {
    if (_shortcutHandler) document.removeEventListener('keydown', _shortcutHandler);
    if (!settings.shortcutOn || !settings.shortcutKey) return;

    const sc = parseShortcut(settings.shortcutKey);
    _shortcutHandler = e => {
      if (e.key.toLowerCase() !== sc.key) return;
      if (e.altKey   !== sc.alt)   return;
      if (e.ctrlKey  !== sc.ctrl)  return;
      if (e.shiftKey !== sc.shift) return;
      if (e.metaKey  !== sc.meta)  return;
      e.preventDefault();
      togglePanel();
    };
    document.addEventListener('keydown', _shortcutHandler);
  }

  function togglePanel() {
    const body = document.getElementById('gsr-body');
    const chev = document.getElementById('gsr-chevron');
    if (!body) return;
    const isHidden = body.style.display === 'none';
    body.style.display = isHidden ? '' : 'none';
    if (chev) chev.style.transform = isHidden ? '' : 'rotate(-90deg)';
    settings.panelOpen = isHidden; // we just opened it if it was hidden
    saveSettings();
  }

  // ════════════════════════════════════════════════════════════
  //  PANEL SCAFFOLD
  // ════════════════════════════════════════════════════════════
  function buildPanel() {
    if (document.getElementById(PANEL_ID)) return document.getElementById(PANEL_ID);
    injectStyles();

    const sidebar =
      document.querySelector('[data-testid="sidebar"]') ||
      document.querySelector('.Layout-sidebar') ||
      document.querySelector('.repository-sidebar') ||
      document.querySelector('aside') ||
      document.querySelector('.BorderGrid');

    if (!sidebar) return null;

    const wrap = document.createElement('div');
    wrap.id = PANEL_ID;
    wrap.innerHTML = `
      <div class="gsr-header">
        <div class="gsr-title-row" id="gsr-toggle">
          <div class="gsr-title-pill">
            <!-- Similar: branching/network icon -->
            <svg class="gsr-title-icon" viewBox="0 0 16 16" fill="currentColor">
              <path d="M5 3.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.122a2.25 2.25 0 1 0-1.5 0v.878A2.25 2.25 0 0 0 5.75 8.5h4.5a.75.75 0 0 1 .75.75v.879a2.25 2.25 0 1 0 1.5 0V9.25a2.25 2.25 0 0 0-2.25-2.25h-4.5a.75.75 0 0 1-.75-.75v-.878Zm5.5 1.628a.75.75 0 1 1 1.5 0 .75.75 0 0 1-1.5 0Zm-6.75 4a.75.75 0 1 1 1.5 0 .75.75 0 0 1-1.5 0Z"/>
            </svg>
            <span class="gsr-title-text">Similar Repos</span>
          </div>
          <span class="gsr-chevron" id="gsr-chevron">▾</span>
        </div>
        <div class="gsr-header-actions">
          <!-- Enabled/disabled pill toggle -->
          <button class="gsr-enable-pill ${settings.enabled ? 'on' : 'off'}" id="gsr-enable-pill" title="${settings.enabled ? 'Click to disable Similar Repos' : 'Click to enable Similar Repos'}">
            <span class="gsr-enable-dot"></span>
            <span id="gsr-enable-label">${settings.enabled ? 'On' : 'Off'}</span>
          </button>
          <button class="gsr-icon-btn" id="gsr-open-all-btn" title="Open visible repos in new tabs (up to 10)">
            <svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor">
              <path d="M1 2.5A1.5 1.5 0 0 1 2.5 1h6A1.5 1.5 0 0 1 10 2.5v1a.75.75 0 0 1-1.5 0v-1h-6v6h1a.75.75 0 0 1 0 1.5h-1A1.5 1.5 0 0 1 1 8.5v-6Z"/>
              <path d="M6.5 6a1.5 1.5 0 0 1 1.5-1.5h6A1.5 1.5 0 0 1 15.5 6v6a1.5 1.5 0 0 1-1.5 1.5H8A1.5 1.5 0 0 1 6.5 12V6Zm1.5 0v6h6V6H8Z"/>
            </svg>
          </button>
          <!-- Copy markdown: clipboard icon -->
          <button class="gsr-icon-btn" id="gsr-export-btn" title="Copy list as Markdown">
            <svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor">
              <path d="M5.75 1a.75.75 0 0 0-.75.75v1.5c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75V1.75a.75.75 0 0 0-.75-.75h-4.5Zm-.25 2.75v-.5h5v.5h-5Zm-2.5-.5h1v.5A1.75 1.75 0 0 0 5.75 5.5h4.5A1.75 1.75 0 0 0 12 3.75v-.5h1A1.5 1.5 0 0 1 14.5 4.75v8.5A1.5 1.5 0 0 1 13 14.75H3A1.5 1.5 0 0 1 1.5 13.25v-8.5A1.5 1.5 0 0 1 3 3.25Zm1 8.5a.75.75 0 0 0 0 1.5h6a.75.75 0 0 0 0-1.5H4Zm0-2.5a.75.75 0 0 0 0 1.5h6a.75.75 0 0 0 0-1.5H4Z"/>
            </svg>
          </button>
          <!-- Settings: gear icon -->
          <button class="gsr-icon-btn" id="gsr-settings-btn" title="Settings">
            <svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor">
              <path fill-rule="evenodd" d="M7.429 1.525a6.593 6.593 0 0 1 1.142 0c.036.003.108.036.137.146l.289 1.105c.147.56.55.967.997 1.189.174.086.341.178.502.274.45.263.98.314 1.465.15l1.094-.364c.105-.035.192.016.229.051.88.88 1.52 1.965 1.832 3.166.028.111-.013.207-.103.255l-.985.54c-.453.247-.71.707-.704 1.188.002.172.002.343 0 .515-.006.48.25.941.703 1.188l.985.54c.09.048.131.144.103.255a8.593 8.593 0 0 1-1.832 3.166c-.037.035-.124.086-.229.051l-1.094-.364c-.485-.164-1.015-.113-1.465.15a6.3 6.3 0 0 1-.502.274c-.447.222-.85.629-.997 1.189l-.289 1.105c-.029.11-.101.143-.137.146a6.613 6.613 0 0 1-1.142 0c-.036-.003-.108-.037-.137-.146l-.289-1.105c-.147-.56-.55-.967-.997-1.189a6.3 6.3 0 0 1-.502-.274c-.45-.263-.98-.314-1.465-.15l-1.094.364c-.105.035-.192-.016-.229-.051a8.593 8.593 0 0 1-1.832-3.166c-.028-.111.013-.207.103-.255l.985-.54c.453-.247.71-.708.703-1.188a7.022 7.022 0 0 1 0-.515c.007-.48-.25-.941-.703-1.188l-.985-.54c-.09-.048-.131-.144-.103-.255a8.593 8.593 0 0 1 1.832-3.166c.037-.035.124-.086.229-.051l1.094.364c.485.164 1.015.113 1.465-.15.161-.096.328-.188.502-.274.447-.222.85-.629.997-1.189l.289-1.105c.029-.11.101-.143.137-.146ZM8 11a3 3 0 1 1 0-6 3 3 0 0 1 0 6Z" clip-rule="evenodd"/>
            </svg>
          </button>
        </div>
      </div>
      <div class="gsr-body" id="gsr-body">
        <div class="gsr-tabs" id="gsr-tabs">
          <div class="gsr-tab active" data-tab="similar">
            <span class="gsr-tab-label">Similar</span>
            <span class="gsr-tab-badge" id="gsr-tab-similar-count">…</span>
          </div>
          <div class="gsr-tab" data-tab="saved">
            <span class="gsr-tab-label">Saved</span>
            <span class="gsr-tab-badge" id="gsr-tab-saved-count">0</span>
          </div>
          <div class="gsr-tab" data-tab="recent">
            <span class="gsr-tab-label">Recent</span>
            <span class="gsr-tab-badge" id="gsr-tab-recent-count">0</span>
          </div>
          <div class="gsr-tab" data-tab="dismissed">
            <span class="gsr-tab-label">Hidden</span>
            <span class="gsr-tab-badge" id="gsr-tab-dismissed-count">0</span>
          </div>
        </div>
        <div id="gsr-tab-content"></div>
      </div>
    `;

    // Collapse/expand — persist state
    wrap.querySelector('#gsr-toggle').onclick = () => {
      const body = wrap.querySelector('#gsr-body');
      const chev = wrap.querySelector('#gsr-chevron');
      const isHidden = body.style.display === 'none';
      body.style.display = isHidden ? '' : 'none';
      chev.style.transform = isHidden ? '' : 'rotate(-90deg)';
      settings.panelOpen = isHidden; // isHidden was true → we're opening it now
      saveSettings();
    };

    // Apply saved open/closed state immediately
    if (!settings.panelOpen) {
      wrap.querySelector('#gsr-body').style.display = 'none';
      wrap.querySelector('#gsr-chevron').style.transform = 'rotate(-90deg)';
    }

    // Apply disabled visual state
    if (!settings.enabled) wrap.classList.add('gsr-disabled');

    // Enable/disable pill
    wrap.querySelector('#gsr-enable-pill').onclick = e => {
      e.stopPropagation();
      settings.enabled = !settings.enabled;
      saveSettings();

      const pill  = wrap.querySelector('#gsr-enable-pill');
      const label = wrap.querySelector('#gsr-enable-label');
      const body  = wrap.querySelector('#gsr-body');

      pill.classList.toggle('on',  settings.enabled);
      pill.classList.toggle('off', !settings.enabled);
      label.textContent = settings.enabled ? 'On' : 'Off';
      pill.title = settings.enabled ? 'Click to disable Similar Repos' : 'Click to enable Similar Repos';
      wrap.classList.toggle('gsr-disabled', !settings.enabled);

      if (settings.enabled) {
        // Re-open body if it was open before, then kick off a fetch for current repo
        if (settings.panelOpen) body.style.display = '';
        if (!currentResult) {
          renderTabContent(wrap); // show spinner
          fetchAndRender(wrap, currentOwner, currentRepo);
        } else {
          renderTabContent(wrap);
        }
      }
    };

    // Tab switching
    wrap.querySelector('#gsr-tabs').addEventListener('click', e => {
      const tab = e.target.closest('[data-tab]');
      if (!tab) return;
      activeTab = tab.dataset.tab;
      wrap.querySelectorAll('.gsr-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === activeTab));
      renderTabContent(wrap);
    });

    // Settings button
    wrap.querySelector('#gsr-settings-btn').onclick = e => {
      e.stopPropagation();
      openSettings();
    };

    // ── Shared helper: get currently visible items for active tab ──
    function getTabItems() {
      if (activeTab === 'similar' && currentResult?.items) {
        return applyFiltersAndSort(currentResult.items);
      } else if (activeTab === 'saved') {
        return Object.values(saved);
      } else if (activeTab === 'recent') {
        return getRecentItems();
      } else if (activeTab === 'dismissed') {
        return Object.keys(dismissed).map(slug => {
          const fromResult = currentResult?.items?.find(i => i.slug === slug);
          const fromRecent = recent[slug];
          return fromResult || fromRecent || { slug, href: `https://github.com/${slug}`, stars: null, pushedAt: null, desc: '', language: null, archived: false };
        });
      }
      return [];
    }

    // Open all in tabs — GM_openInTab bypasses popup blockers entirely,
    // it's a privileged userscript API that doesn't need a user gesture chain.
    wrap.querySelector('#gsr-open-all-btn').onclick = e => {
      e.stopPropagation();
      const visible = getTabItems().slice(0, 10);
      if (!visible.length) return;

      const now = Date.now();
      visible.forEach(it => {
        GM_openInTab(it.href, { active: false, insert: true });
        visited[it.slug] = now;
        const source = currentResult?.items?.find(r => r.slug === it.slug) || it;
        recent[it.slug] = { ...source, visitedAt: now };
      });

      saveVisited();
      saveRecent();
      updateTabCounts(wrap);
    };

    // Export as markdown — uses active tab items
    wrap.querySelector('#gsr-export-btn').onclick = e => {
      e.stopPropagation();
      const btn   = wrap.querySelector('#gsr-export-btn');
      const items = getTabItems();
      if (!items.length) return;
      const lines = items.map(it =>
        `- [${it.slug}](${it.href})${it.stars ? ` — ★${formatStars(it.stars)}` : ''}${it.desc ? ` — ${it.desc}` : ''}`
      );
      navigator.clipboard.writeText(lines.join('\n')).then(() => {
        btn.title = 'Copied!';
        setTimeout(() => { btn.title = 'Copy list as Markdown'; }, 2000);
      });
    };

    sidebar.insertBefore(wrap, sidebar.firstChild);
    return wrap;
  }

  // ════════════════════════════════════════════════════════════
  //  TAB RENDERING
  // ════════════════════════════════════════════════════════════
  function updateTabCounts(panel) {
    const similarCount = currentResult?.items?.filter(it => !dismissed[it.slug]).length ?? '…';
    panel.querySelector('#gsr-tab-similar-count').textContent   = similarCount;
    panel.querySelector('#gsr-tab-saved-count').textContent     = Object.keys(saved).length;
    panel.querySelector('#gsr-tab-recent-count').textContent    = getRecentItems().length;
    panel.querySelector('#gsr-tab-dismissed-count').textContent = Object.keys(dismissed).length;
  }

  // Returns recent items visited within the last 7 days, sorted newest first
  function getRecentItems() {
    const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
    return Object.values(recent)
      .filter(r => r.visitedAt > cutoff)
      .sort((a, b) => b.visitedAt - a.visitedAt);
  }

  function renderTabContent(panel) {
    updateTabCounts(panel);
    const container = panel.querySelector('#gsr-tab-content');
    container.innerHTML = '';

    if (activeTab === 'similar') {
      renderSimilarTab(container, panel);
    } else if (activeTab === 'saved') {
      renderSavedTab(container);
    } else if (activeTab === 'recent') {
      renderRecentTab(container);
    } else if (activeTab === 'dismissed') {
      renderDismissedTab(container);
    }
  }

  // ── Similar tab ─────────────────────────────────────────────
  function renderSimilarTab(container, panel) {
    // Show loading
    if (!currentResult) {
      container.innerHTML = `<div class="gsr-loading"><span class="gsr-spinner"></span>Finding similar repos…</div>`;
      return;
    }

    const items = applyFiltersAndSort(currentResult.items);
    const langs = uniqueLangs(currentResult.items);

    // Controls
    const controlsEl = document.createElement('div');
    controlsEl.innerHTML = `
      <div class="gsr-controls">
        <select class="gsr-select" id="gsr-sort">
          <option value="relevance" ${settings.sortBy==='relevance'?'selected':''}>Sort: Relevance</option>
          <option value="stars"     ${settings.sortBy==='stars'    ?'selected':''}>Sort: Stars</option>
          <option value="activity"  ${settings.sortBy==='activity' ?'selected':''}>Sort: Activity</option>
        </select>
      </div>
      ${langs.length > 1 ? `
      <div class="gsr-lang-chips" id="gsr-lang-chips">
        <button class="gsr-chip ${!settings.langFilter?'active':''}" data-lang="">All</button>
        ${langs.map(l => `<button class="gsr-chip ${settings.langFilter===l?'active':''}" data-lang="${esc(l)}">${esc(l)}</button>`).join('')}
      </div>` : ''}
    `;
    container.appendChild(controlsEl);

    controlsEl.querySelector('#gsr-sort').onchange = e => {
      settings.sortBy = e.target.value;
      saveSettings();
      renderTabContent(panel);
    };
    controlsEl.querySelector('#gsr-lang-chips')?.addEventListener('click', e => {
      const chip = e.target.closest('[data-lang]');
      if (!chip) return;
      settings.langFilter = chip.dataset.lang;
      saveSettings();
      renderTabContent(panel);
    });

    // Rate limit warning
    if (currentResult._rateLimited) {
      const warn = document.createElement('div');
      warn.className = 'gsr-ratelimit';
      warn.innerHTML = `⚡ GitHub API rate limit hit — some star counts may be missing. <a href="#" id="gsr-add-token-link">Add a token</a> in settings for 5,000 req/hr.`;
      container.appendChild(warn);
      warn.querySelector('#gsr-add-token-link').onclick = e => { e.preventDefault(); openSettings(); };
    }

    if (!items.length) {
      const empty = document.createElement('div');
      empty.className = 'gsr-loading';
      empty.textContent = settings.langFilter
        ? `No ${settings.langFilter} repos found. Try a different language filter.`
        : 'No similar repos found for this project.';
      container.appendChild(empty);
      return;
    }

    const scroll = document.createElement('div');
    scroll.className = 'gsr-scroll';
    scroll.innerHTML = items.map(it => renderCard(it)).join('');
    container.appendChild(scroll);

    // Wire card buttons
    scroll.addEventListener('click', e => {
      const btn = e.target.closest('[data-action]');
      if (!btn) return;
      e.preventDefault(); e.stopPropagation();
      const action = btn.dataset.action;
      const slug   = btn.dataset.slug;
      const item   = currentResult.items.find(i => i.slug === slug);

      if (action === 'dismiss') {
        dismissed[slug] = true;
        saveDismissed();
        renderTabContent(panel);
      } else if (action === 'save') {
        if (saved[slug]) {
          delete saved[slug];
        } else if (item) {
          saved[slug] = item;
        }
        saveSaved();
        renderTabContent(panel);
      } else if (action === 'gh-star') {
        if (!settings.token) return; // no-token state, do nothing
        toggleGhStar(slug).then(({ ok, nowStarred }) => {
          if (!ok) return;
          // Update just this button in place without full re-render
          const starBtn = scroll.querySelector(`[data-action="gh-star"][data-slug="${slug}"]`);
          if (starBtn) {
            starBtn.classList.toggle('starred', nowStarred);
            starBtn.title = nowStarred ? 'Unstar on GitHub' : 'Star on GitHub';
            starBtn.innerHTML = nowStarred ? SVG.starFilled : SVG.starHollow;
          }
        });
      }
    });

    // Track visits — write to both visited (for dimming) and recent (for tab)
    scroll.addEventListener('click', e => {
      const card = e.target.closest('a.gsr-item');
      if (!card) return;
      const slug = card.dataset.slug;
      if (!slug) return;
      const now = Date.now();
      visited[slug] = now;
      saveVisited();
      // Also store in recent with full metadata for the Recent tab
      const item = currentResult?.items?.find(i => i.slug === slug);
      if (item) {
        recent[slug] = { ...item, visitedAt: now };
        saveRecent();
      }
      updateTabCounts(document.getElementById(PANEL_ID));
    });

    // Footer
    const footer = document.createElement('div');
    footer.className = 'gsr-footer';
    const src = currentResult.source || 'LibHunt';
    const lhLink = currentResult.libhuntUrl
      ? `<a href="${currentResult.libhuntUrl}" target="_blank" rel="noopener noreferrer">LibHunt ↗</a>`
      : '';
    footer.innerHTML = `
      <span>${items.length} result${items.length!==1?'s':''} · ${src}</span>
      <div class="gsr-footer-actions">
        ${lhLink}
        <button class="gsr-footer-btn" id="gsr-refresh-btn">↻ Refresh</button>
      </div>
    `;
    container.appendChild(footer);

    footer.querySelector('#gsr-refresh-btn').onclick = () => {
      GM_deleteValue(KEY.cache(currentOwner, currentRepo));
      currentResult = null;
      renderTabContent(panel);
      fetchAndRender(panel, currentOwner, currentRepo);
    };
  }

  // ── Saved tab ───────────────────────────────────────────────
  function renderSavedTab(container) {
    const items = Object.values(saved);
    if (!items.length) {
      container.innerHTML = `<div class="gsr-loading" style="flex-direction:column;align-items:flex-start;gap:4px">
        <span>No saved repos yet.</span>
        <span style="font-size:${FONT_SIZES[settings.fontSize]==='13px'?'11.5px':'10px'};color:var(--color-fg-subtle,#848d97)">Bookmark repos with the ★ button on any result.</span>
      </div>`;
      return;
    }

    const scroll = document.createElement('div');
    scroll.className = 'gsr-scroll';
    scroll.innerHTML = items.map(it => renderCard(it, { inSaved: true })).join('');
    container.appendChild(scroll);

    scroll.addEventListener('click', e => {
      const btn = e.target.closest('[data-action]');
      if (!btn) return;
      e.preventDefault(); e.stopPropagation();
      const slug = btn.dataset.slug;
      if (btn.dataset.action === 'unsave') {
        delete saved[slug];
        saveSaved();
        renderTabContent(document.getElementById(PANEL_ID));
      } else if (btn.dataset.action === 'gh-star') {
        if (!settings.token) return;
        toggleGhStar(slug).then(({ ok, nowStarred }) => {
          if (!ok) return;
          const starBtn = scroll.querySelector(`[data-action="gh-star"][data-slug="${slug}"]`);
          if (starBtn) {
            starBtn.classList.toggle('starred', nowStarred);
            starBtn.title = nowStarred ? 'Unstar on GitHub' : 'Star on GitHub';
            starBtn.innerHTML = nowStarred ? SVG.starFilled : SVG.starHollow;
          }
        });
      }
    });
  }

  // ── Recent tab (last 7 days of clicked-through repos) ───────
  function renderRecentTab(container) {
    const items = getRecentItems();
    if (!items.length) {
      container.innerHTML = `<div class="gsr-loading" style="flex-direction:column;align-items:flex-start;gap:4px">
        <span>No recently visited repos.</span>
        <span style="font-size:11px;color:var(--color-fg-subtle,#848d97)">Repos you click from the Similar tab appear here for 7 days.</span>
      </div>`;
      return;
    }

    const scroll = document.createElement('div');
    scroll.className = 'gsr-scroll';
    scroll.innerHTML = items.map(it => {
      const ago        = timeAgo(it.visitedAt);
      // Use visitedAt for freshness so recent visits are green, older are amber/red
      const dotColor   = freshnessColor(new Date(it.visitedAt).toISOString());
      const borderColor = dotColor + '55';
      return `
        <div class="gsr-item" style="cursor:default">
          <div class="gsr-item-top">
            <a href="${esc(it.href)}" class="gsr-repo-name" style="text-decoration:none">${esc(it.slug)}</a>
            <span class="gsr-badges">
              ${it.stars ? `<span class="gsr-stars">★ ${esc(formatStars(it.stars))}</span>` : ''}
              ${ago ? `<span class="gsr-activity" style="color:${dotColor};border-color:${borderColor};background:${dotColor}18">
                <span class="gsr-dot" style="background:${dotColor}"></span>visited ${esc(ago)}
              </span>` : ''}
            </span>
          </div>
          ${it.desc ? `<span class="gsr-desc">${esc(it.desc)}</span>` : ''}
          ${it.language ? `<div class="gsr-item-meta"><span class="gsr-lang-chip">${esc(it.language)}</span></div>` : ''}
        </div>
      `;
    }).join('');
    container.appendChild(scroll);

    // Footer with clear button
    const footer = document.createElement('div');
    footer.className = 'gsr-footer';
    footer.innerHTML = `
      <span>${items.length} visited in last 7 days</span>
      <button class="gsr-footer-btn" id="gsr-clear-recent">Clear history</button>
    `;
    container.appendChild(footer);
    footer.querySelector('#gsr-clear-recent').onclick = () => {
      recent = {};
      saveRecent();
      renderTabContent(document.getElementById(PANEL_ID));
    };
  }

  // ── Dismissed tab ───────────────────────────────────────────
  function renderDismissedTab(container) {
    const slugs = Object.keys(dismissed);
    if (!slugs.length) {
      container.innerHTML = `<div class="gsr-loading" style="flex-direction:column;align-items:flex-start;gap:4px">
        <span>No hidden repos.</span>
        <span style="font-size:${FONT_SIZES[settings.fontSize]==='13px'?'11.5px':'10px'};color:var(--color-fg-subtle,#848d97)">Dismiss repos with the ✕ button on any result.</span>
      </div>`;
      return;
    }

    // Build item objects: use stored metadata from currentResult or recent, fall back to slug-only stub
    const items = slugs.map(slug => {
      const fromResult = currentResult?.items?.find(i => i.slug === slug);
      const fromRecent = recent[slug];
      return fromResult || fromRecent || { slug, href: `https://github.com/${slug}`, desc: '', stars: null, pushedAt: null, language: null, archived: false };
    });

    const scroll = document.createElement('div');
    scroll.className = 'gsr-scroll';
    scroll.innerHTML = items.map(it => renderCard(it, { inDismissed: true })).join('');
    container.appendChild(scroll);

    scroll.addEventListener('click', e => {
      const btn = e.target.closest('[data-action]');
      if (btn) {
        e.preventDefault(); e.stopPropagation();
        const slug = btn.dataset.slug;
        if (btn.dataset.action === 'undismiss') {
          delete dismissed[slug];
          saveDismissed();
          renderTabContent(document.getElementById(PANEL_ID));
        } else if (btn.dataset.action === 'gh-star') {
          if (!settings.token) return;
          toggleGhStar(slug).then(({ ok, nowStarred }) => {
            if (!ok) return;
            const starBtn = scroll.querySelector(`[data-action="gh-star"][data-slug="${slug}"]`);
            if (starBtn) {
              starBtn.classList.toggle('starred', nowStarred);
              starBtn.title = nowStarred ? 'Unstar on GitHub' : 'Star on GitHub';
              starBtn.innerHTML = nowStarred ? SVG.starFilled : SVG.starHollow;
            }
          });
        }
        return;
      }
      // Clicking the card body (not a button) opens the repo in a new tab
      const card = e.target.closest('.gsr-item[data-href]');
      if (card) {
        e.preventDefault();
        const href = card.dataset.href;
        if (href) window.open(href, '_blank', 'noopener,noreferrer');
      }
    });

    const footer = document.createElement('div');
    footer.className = 'gsr-footer';
    footer.innerHTML = `<span>${items.length} hidden repo${items.length !== 1 ? 's' : ''}</span>`;
    container.appendChild(footer);
  }

  // ── Card renderer ────────────────────────────────────────────
  // SVG icon strings
  const SVG = {
    // Hollow bookmark (unsaved)
    bookmarkHollow: `<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 2.5a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1v11.792l-4.5-2.796L4 14.792 3 14.5V2.5Z"/></svg>`,
    // Filled bookmark (saved) - filled with accent blue
    bookmarkFilled: `<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M3 2.5a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1v11.792l-4.5-2.796L4 14.292V2.5Z"/></svg>`,
    // Hollow star (not GH-starred)
    starHollow: `<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 1.5l1.9 3.9 4.3.6-3.1 3 .7 4.3L8 11.1l-3.8 2.2.7-4.3-3.1-3 4.3-.6L8 1.5Z"/></svg>`,
    // Filled star (GH-starred)
    starFilled: `<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M8 1.5l1.9 3.9 4.3.6-3.1 3 .7 4.3L8 11.1l-3.8 2.2.7-4.3-3.1-3 4.3-.6L8 1.5Z"/></svg>`,
    // Undo / unhide
    undismiss: `<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M1.5 3.5A.5.5 0 0 1 2 3h4a.5.5 0 0 1 0 1H3.707l2.147 2.146a.5.5 0 0 1-.708.708L3 4.707V6a.5.5 0 0 1-1 0V3.5ZM13 8a5 5 0 1 1-10 0 5 5 0 0 1 10 0Zm-4.5-2a.5.5 0 0 0-1 0v2.293l-1.146-1.147a.5.5 0 0 0-.708.708l2 2a.5.5 0 0 0 .708 0l2-2a.5.5 0 0 0-.708-.708L8.5 8.293V6Z"/></svg>`,
  };

  function renderCard(it, { inSaved = false, inDismissed = false } = {}) {
    const { slug, href, stars, pushedAt, desc, language, archived } = it;
    const starsStr    = formatStars(stars);
    const ago         = timeAgo(pushedAt);
    const dotColor    = freshnessColor(pushedAt);
    const borderColor = dotColor + '55';
    const isVisited   = !!visited[slug];
    const isSaved     = !!saved[slug];
    const isGhStarred = !!ghStarred[slug];
    const hasToken    = !!settings.token;

    // GH star button — hollow/gold star; greyed out + no-token class if no API token
    const ghStarBtn = `<button
      class="gsr-action-btn gh-star-btn${isGhStarred ? ' starred' : ''}${!hasToken ? ' no-token' : ''}"
      data-action="gh-star"
      data-slug="${esc(slug)}"
      title="${!hasToken ? 'Add a GitHub token in Settings to star repos' : isGhStarred ? 'Unstar on GitHub' : 'Star on GitHub'}"
    >${isGhStarred ? SVG.starFilled : SVG.starHollow}</button>`;

    let actionBtns;
    if (inDismissed) {
      // Unhide + GH star available on hidden cards too
      actionBtns = `
        ${ghStarBtn}
        <button class="gsr-action-btn" data-action="undismiss" data-slug="${esc(slug)}" title="Unhide this repo">${SVG.undismiss}</button>
      `;
    } else if (inSaved) {
      actionBtns = `
        ${ghStarBtn}
        <button class="gsr-action-btn is-saved" data-action="unsave" data-slug="${esc(slug)}" title="Remove from saved">${SVG.bookmarkFilled}</button>
      `;
    } else {
      actionBtns = `
        ${ghStarBtn}
        <button class="gsr-action-btn${isSaved ? ' is-saved' : ''}" data-action="save" data-slug="${esc(slug)}" title="${isSaved ? 'Remove from saved' : 'Save this repo'}">${isSaved ? SVG.bookmarkFilled : SVG.bookmarkHollow}</button>
        <button class="gsr-action-btn" data-action="dismiss" data-slug="${esc(slug)}" title="Hide this repo">✕</button>
      `;
    }

    // For dismissed tab, wrap in a div (not <a>) so clicking the card doesn't navigate away
    const tag     = inDismissed ? 'div' : 'a';
    const hrefAttr = inDismissed ? '' : `href="${esc(href)}"`;
    const extraStyle = inDismissed ? 'cursor:default;' : '';

    return `
      <${tag} class="gsr-item${isVisited ? ' visited' : ''}${archived ? ' archived' : ''}"
         ${hrefAttr} data-slug="${esc(slug)}" data-href="${esc(href)}" style="${extraStyle}" title="${esc(slug)}">
        <div class="gsr-item-top">
          <span class="gsr-repo-name"${inDismissed ? ` onclick="window.open('${esc(href)}','_blank','noopener,noreferrer');event.stopPropagation()" style="cursor:pointer"` : ''}>${esc(slug)}</span>
          <span class="gsr-badges">
            ${starsStr ? `<span class="gsr-stars">★ ${esc(starsStr)}</span>` : ''}
            ${archived ? `<span class="gsr-archived-badge">Archived</span>` : ''}
            ${ago ? `<span class="gsr-activity" style="color:${dotColor};border-color:${borderColor};background:${dotColor}18">
              <span class="gsr-dot" style="background:${dotColor}"></span>${esc(ago)}
            </span>` : ''}
          </span>
        </div>
        ${desc ? `<span class="gsr-desc">${esc(desc)}</span>` : ''}
        ${language ? `<div class="gsr-item-meta"><span class="gsr-lang-chip">${esc(language)}</span></div>` : ''}
        <div class="gsr-item-actions">${actionBtns}</div>
      </${tag}>
    `;
  }

  // ════════════════════════════════════════════════════════════
  //  MAIN FETCH + RENDER FLOW
  // ════════════════════════════════════════════════════════════
  async function fetchAndRender(panel, owner, repo) {
    try {
      const libhuntData = await fetchLibHunt(owner, repo);

      if (libhuntData) {
        const { items, rateLimited } = await enrichSlugs(libhuntData.slugs);
        currentResult = {
          source: 'LibHunt', libhuntUrl: libhuntData.libhuntUrl,
          items, _rateLimited: rateLimited,
        };
      } else {
        const fallback = await fetchGitHubTopics(owner, repo);
        currentResult = fallback || { source: 'No results', libhuntUrl: null, items: [], _rateLimited: false };
      }

      GM_setValue(KEY.cache(owner, repo), JSON.stringify({ ts: Date.now(), data: currentResult }));
      renderTabContent(panel);
    } catch {
      currentResult = { source: 'Error', libhuntUrl: null, items: [], _rateLimited: false };
      renderTabContent(panel);
    }
  }

  async function run() {
    const parts = getRepoParts();
    if (!parts) return;

    const skipOwners = ['settings','notifications','organizations','marketplace','explore','login','join'];
    if (skipOwners.includes(parts.owner)) return;

    currentOwner = parts.owner;
    currentRepo  = parts.repo;
    currentResult = null;
    activeTab = 'similar';

    const panel = buildPanel();
    if (!panel) return;

    // If disabled, show a quiet disabled state and stop — don't fetch anything
    if (!settings.enabled) {
      const container = panel.querySelector('#gsr-tab-content');
      if (container) {
        container.innerHTML = `<div class="gsr-loading" style="flex-direction:column;align-items:flex-start;gap:4px">
          <span style="color:var(--color-fg-muted,#656d76)">Similar Repos is turned off.</span>
          <span style="font-size:11px;color:var(--color-fg-subtle,#848d97)">Toggle it on above to find similar repos.</span>
        </div>`;
      }
      updateTabCounts(panel);
      return;
    }

    // Try cache
    try {
      const raw = GM_getValue(KEY.cache(parts.owner, parts.repo), null);
      if (raw) {
        const { ts, data } = JSON.parse(raw);
        if (Date.now() - ts < CACHE_TTL) {
          currentResult = data;
          renderTabContent(panel);
          return;
        }
      }
// eslint-disable-next-line no-empty
    } catch {}

    renderTabContent(panel); // show spinner
    fetchAndRender(panel, parts.owner, parts.repo);
  }

  // ════════════════════════════════════════════════════════════
  //  INIT + SPA WATCHER
  // ════════════════════════════════════════════════════════════
  loadState();
  rebindShortcut();

  let lastPath = location.pathname;
  new MutationObserver(() => {
    if (location.pathname !== lastPath) {
      lastPath = location.pathname;
      setTimeout(() => {
        document.getElementById(PANEL_ID)?.remove();
        document.getElementById('gsr-styles')?.remove();
        run();
      }, 800);
    }
  }).observe(document.body, { childList: true, subtree: true });

  run();
})();