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 यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

You will need to install an extension such as Tampermonkey to install this script.

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला 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();
})();