---
import SiteLayout from '../components/SiteLayout.astro';
import fixture from '../data/plugins-fixture.json';
const snapshotUrl = 'https://assets.herdr.dev/plugins/index.json';

// Seed the page server-side from the public R2 snapshot so `bun run dev` and
// the production build both render real data immediately. If the live fetch
// fails, dev falls back to a local fixture for offline design; production seeds
// empty and lets the client fetch fill it (never fake data).
let liveData = null;
try {
  const res = await fetch(snapshotUrl);
  if (res.ok) liveData = await res.json();
} catch {
  // ignore: handled by the fallbacks below
}
const initialData = liveData ?? (import.meta.env.DEV ? fixture : null);
const initialIsFixture = !liveData && import.meta.env.DEV;
// A card is a repository; a plugin is a validated manifest inside it. A monorepo
// with three manifests is one card and three plugins, so the two counts differ
// and both are stated. `source.totalCount` is the raw GitHub topic search and
// still includes blacklisted and manifest-less repositories, so it is not used.
const repos = initialData?.plugins ?? [];
const repositoryCount = initialData?.repositoryCount ?? repos.length;
const pluginCount =
  initialData?.pluginCount ??
  repos.reduce((n, repo) => n + (repo.manifests?.length || 1), 0);
const apiUrl = import.meta.env.DEV ? null : snapshotUrl;
---

<SiteLayout
  title="Herdr plugins: community plugin marketplace"
  description="Discover community Herdr plugins published on GitHub. An automatic index of public repositories tagged with the herdr-plugin topic."
  canonicalPath="/plugins/"
>
  <main class="plugins-page">
    <header class="ph ph-split">
      <div>
      <div class="hd-eyebrow"><s></s>the marketplace</div>
      <h1>Built by the herd.</h1>
      <p>
        Community plugins published on GitHub, discovered automatically from the
        <code>herdr-plugin</code> topic. Install any of them with
        <code>herdr plugin install</code>, or publish your own and get listed.
      </p>
      <p class="plugins-trust">
        <span class="status-dot dot-idle"></span>
        Listings aren't reviewed by Herdr, so install at your own discretion.
        <a href="/docs/plugins/#trust-and-security">Trust guidance →</a>
      </p>
      </div>
      <aside class="ph-side">
        <b id="plugins-hero-count">{pluginCount.toLocaleString('en-US')}</b>
        <span id="plugins-hero-summary">
          plugins across {repositoryCount.toLocaleString('en-US')} repositories
        </span>
        <a href="/docs/plugins/">Publish yours →</a>
      </aside>
    </header>

    <div id="plugins-shelves" hidden>
      <section class="section plugins-shelf" id="shelf-trending" hidden>
        <div class="shelf-head">
          <div>
            <h2 class="shelf-title">Trending this week</h2>
            <p class="shelf-sub">fastest-growing repositories, relative to their own size</p>
          </div>
          <button class="shelf-all" type="button" data-sort="trending">see all →</button>
        </div>
        <div id="trend-grid" class="trend-grid" role="list"></div>
      </section>

      <section class="section plugins-shelf" id="shelf-new" hidden>
        <div class="shelf-head">
          <div>
            <h2 class="shelf-title">New to the herd</h2>
            <p class="shelf-sub">fresh projects that joined recently</p>
          </div>
          <button class="shelf-all" type="button" data-sort="newest">see all →</button>
        </div>
        <div class="new-rows" role="list"></div>
      </section>
    </div>

    <section class="section plugins-results">
      <div class="shelf-head plugins-browse-head" id="plugins-browse-head">
        <div>
          <h2 class="shelf-title">Browse everything</h2>
          <p class="shelf-sub">the full index — search or pick a sort</p>
        </div>
      </div>
      <div class="plugins-controls">
        <div class="plugins-search-wrap">
          <span class="plugins-search-icon" aria-hidden="true">/</span>
          <input
            id="plugins-search"
            type="search"
            class="plugins-search"
            placeholder="search plugins, repositories, owners, languages…"
            autocomplete="off"
            spellcheck="false"
            aria-label="Search plugins"
          />
        </div>
        <label class="plugins-sort-wrap">
          <span class="plugins-sort-label">sort</span>
          <select id="plugins-sort" class="plugins-sort" aria-label="Sort plugins">
            <option value="popular">Popular</option>
            <option value="trending">Trending</option>
            <option value="recent">Recently updated</option>
            <option value="newest">Newest</option>
            <option value="name">Name</option>
          </select>
        </label>
      </div>
      <p id="plugins-meta" class="plugins-meta" aria-live="polite"></p>
      <div id="plugins-grid" class="plugins-grid" role="list"></div>
      <div id="plugins-status" class="plugins-status">Loading plugins…</div>
      <button id="plugins-more" class="button button-secondary plugins-more" hidden></button>
    </section>

    <section class="section plugins-publish">
      <div class="plugins-publish-card">
        <p class="section-kicker"># publish</p>
        <h3>Want your plugin listed?</h3>
        <p>
          Add the GitHub topic <code>herdr-plugin</code> to a public repository
          with a <code>herdr-plugin.toml</code> manifest. The index picks it up
          on the next refresh. No submission, no review queue.
        </p>
        <div class="plugins-publish-actions">
          <a class="button button-primary" href="/docs/marketplace/">How publishing works</a>
          <a class="button button-secondary" href="/docs/plugins/">Author a plugin</a>
        </div>
      </div>
    </section>
  </main>

  <script define:vars={{ initialData, initialIsFixture, apiUrl }}>
    const grid = document.getElementById("plugins-grid");
    const statusEl = document.getElementById("plugins-status");
    const searchInput = document.getElementById("plugins-search");
    const sortSelect = document.getElementById("plugins-sort");
    const metaEl = document.getElementById("plugins-meta");
    const moreButton = document.getElementById("plugins-more");
    const heroCountEl = document.getElementById("plugins-hero-count");
    const heroSummaryEl = document.getElementById("plugins-hero-summary");
    const shelvesWrap = document.getElementById("plugins-shelves");
    const trendingShelf = document.getElementById("shelf-trending");
    const trendGrid = document.getElementById("trend-grid");
    const newShelf = document.getElementById("shelf-new");

    // 24 fills complete rows in both the 3-column and 2-column grid layouts.
    const PAGE_SIZE = 24;
    let visibleLimit = PAGE_SIZE;

    // A shelf earns its place: percent growth only counts once a repository
    // gained real stars this week, so the long tail (median: 1 star) can't
    // flood trending with 1-to-3-star noise, while big repositories must grow
    // at their own scale to appear.
    const TRENDING_MIN_GAIN = 10;
    // "New" means new to everyone: recently indexed AND a recently created
    // repository. Established projects that just adopted the topic surface
    // through trending instead of burying actual newcomers.
    const NEW_SEEN_MAX_DAYS = 14;
    const NEW_CREATED_MAX_DAYS = 60;
    // Six cards fill complete shelf rows in the 3, 2, and 1 column layouts.
    const SHELF_SIZE = 6;
    // A shelf with one or two cards reads as a struggling marketplace, which
    // is worse than no shelf at all.
    const SHELF_MIN_CARDS = 3;

    const LANG_COLORS = {
      TypeScript: "#3178c6",
      JavaScript: "#f1e05a",
      Rust: "#dea584",
      Python: "#3572A5",
      Go: "#00ADD8",
      Lua: "#000080",
      Shell: "#89e051",
      "C++": "#f34b7d",
      Ruby: "#701516",
    };

    // One entry per repository. Its `manifests` are the plugins it ships.
    let repos = [];
    let usingFallback = false;

    function timeAgo(iso) {
      if (!iso) return null;
      const then = new Date(iso).getTime();
      if (Number.isNaN(then)) return null;
      const secs = Math.max(0, Math.round((Date.now() - then) / 1000));
      const units = [
        ["y", 31536000],
        ["mo", 2592000],
        ["w", 604800],
        ["d", 86400],
        ["h", 3600],
        ["m", 60],
      ];
      for (const [label, size] of units) {
        const v = Math.floor(secs / size);
        if (v >= 1) return v + label + " ago";
      }
      return "just now";
    }

    function isGithubUrl(url) {
      return typeof url === "string" && /^https:\/\/github\.com\/[^/]+\/[^/]+/.test(url);
    }

    function daysSince(iso) {
      if (!iso) return null;
      const then = new Date(iso).getTime();
      if (Number.isNaN(then)) return null;
      return (Date.now() - then) / 86400000;
    }

    function trendingScore(p) {
      const delta = p.starsDelta7d;
      if (typeof delta !== "number" || delta < TRENDING_MIN_GAIN) return null;
      return delta / Math.max((p.stars ?? 0) - delta, 1);
    }

    function isNewToHerd(p) {
      const seen = daysSince(p.firstSeenAt);
      const created = daysSince(p.createdAt);
      return (
        seen !== null && seen <= NEW_SEEN_MAX_DAYS &&
        created !== null && created <= NEW_CREATED_MAX_DAYS
      );
    }

    function trendingBadge(p) {
      const delta = p.starsDelta7d ?? 0;
      const base = Math.max((p.stars ?? 0) - delta, 0);
      return "▲ " + base + " → " + (p.stars ?? 0) + " ★ this week";
    }

    function joinedBadge(p) {
      const ago = timeAgo(p.firstSeenAt);
      return ago === "just now" ? "✦ just joined" : ago ? "✦ joined " + ago : null;
    }

    function avatar(p, size) {
      const img = el("img", "plugin-avatar");
      img.src = "https://github.com/" + encodeURIComponent(p.owner || "") + ".png?size=" + size;
      img.alt = "";
      img.loading = "lazy";
      img.width = size / 2;
      img.height = size / 2;
      return img;
    }

    function cardShell(p, className) {
      const url = isGithubUrl(p.url) ? p.url : null;
      const card = el(url ? "a" : "div", className);
      card.setAttribute("role", "listitem");
      if (url) {
        card.href = url;
        card.target = "_blank";
        card.rel = "noopener noreferrer";
      }
      return card;
    }

    function titleOf(p) {
      const title = el("div", "plugin-title");
      title.appendChild(el("span", "plugin-owner", (p.owner || "?") + "/"));
      title.appendChild(el("span", "plugin-name", p.name || "unknown"));
      return title;
    }

    function buildTrendCard(p) {
      const card = cardShell(p, "plugin-card plugin-trend-card");
      const head = el("div", "plugin-card-head");
      const lead = el("div", "trend-lead");
      lead.appendChild(avatar(p, 64));
      lead.appendChild(titleOf(p));
      head.appendChild(lead);
      head.appendChild(el("span", "plugin-go", "↗"));
      card.appendChild(head);
      card.appendChild(el("div", "plugin-badge", trendingBadge(p)));
      const desc = el(
        "p",
        "plugin-desc" + (p.description ? "" : " is-empty"),
        p.description || "No description provided.",
      );
      card.appendChild(desc);
      return card;
    }

    function buildRow(p, note) {
      const row = cardShell(p, "plugin-row");
      row.appendChild(avatar(p, 64));
      const body = el("div", "plugin-row-body");
      body.appendChild(titleOf(p));
      const desc = el(
        "span",
        "plugin-row-desc" + (p.description ? "" : " is-empty"),
        p.description || "No description provided.",
      );
      body.appendChild(desc);
      row.appendChild(body);
      const side = el("div", "plugin-row-side");
      if (note) side.appendChild(el("span", "plugin-row-note", note));
      const stars = el("span", "plugin-stat");
      stars.appendChild(el("span", "plugin-star", "★"));
      stars.appendChild(el("span", null, String(p.stars ?? 0)));
      side.appendChild(stars);
      row.appendChild(side);
      return row;
    }

    function el(tag, className, text) {
      const node = document.createElement(tag);
      if (className) node.className = className;
      if (text != null) node.textContent = text;
      return node;
    }

    function buildCard(p, badge) {
      const url = isGithubUrl(p.url) ? p.url : null;
      const card = el(url ? "a" : "div", "plugin-card");
      card.setAttribute("role", "listitem");
      if (url) {
        card.href = url;
        card.target = "_blank";
        card.rel = "noopener noreferrer";
      }

      const head = el("div", "plugin-card-head");
      const lead = el("div", "trend-lead");
      lead.appendChild(avatar(p, 64));
      lead.appendChild(titleOf(p));
      head.appendChild(lead);
      head.appendChild(el("span", "plugin-go", "↗"));
      card.appendChild(head);
      if (badge) card.appendChild(el("div", "plugin-badge", badge));

      const desc = el(
        "p",
        "plugin-desc" + (p.description ? "" : " is-empty"),
        p.description || "No description provided.",
      );
      card.appendChild(desc);

      const meta = el("div", "plugin-meta-row");

      const stars = el("span", "plugin-stat");
      stars.appendChild(el("span", "plugin-star", "★"));
      stars.appendChild(el("span", null, String(p.stars ?? 0)));
      meta.appendChild(stars);

      if (p.language) {
        const lang = el("span", "plugin-stat");
        const dot = el("span", "plugin-lang-dot");
        dot.style.background = LANG_COLORS[p.language] || "var(--muted)";
        lang.appendChild(dot);
        lang.appendChild(el("span", null, p.language));
        meta.appendChild(lang);
      }

      // Nearly every repository ships exactly one plugin whose name restates the
      // repository name, so only its version earns a place in the meta row.
      const manifests = manifestsOf(p);
      if (manifests.length === 1 && manifests[0].version) {
        meta.appendChild(el("span", "plugin-stat plugin-version", "v" + manifests[0].version));
      }

      const updated = timeAgo(p.pushedAt);
      if (updated) meta.appendChild(el("span", "plugin-stat plugin-updated", "updated " + updated));

      card.appendChild(meta);

      // A monorepo is the case the card title can't answer on its own, so it
      // gets the extra row: how many plugins, and which.
      if (manifests.length > 1) {
        const ships = el("div", "plugin-ships");
        ships.appendChild(el("span", "plugin-ship-count", manifests.length + " plugins"));
        ships.appendChild(
          el("span", "plugin-ship-list", manifests.map(manifestLabel).join(" · ")),
        );
        card.appendChild(ships);
      }

      return card;
    }

    function manifestsOf(p) {
      return Array.isArray(p.manifests) ? p.manifests : [];
    }

    function manifestLabel(m) {
      return m.name || m.id || m.path || "plugin";
    }

    function matches(p, q) {
      if (!q) return true;
      const hay = [
        p.fullName,
        p.owner,
        p.name,
        p.description,
        p.language,
        ...(p.topics || []),
        ...manifestsOf(p).flatMap((m) => [m.name, m.id, m.description]),
      ]
        .filter(Boolean)
        .join(" ")
        .toLowerCase();
      return q.split(/\s+/).every((term) => hay.includes(term));
    }

    function sortRepos(list, mode) {
      const by = [...list];
      const t = (v) => new Date(v || 0).getTime() || 0;
      if (mode === "recent") by.sort((a, b) => t(b.pushedAt) - t(a.pushedAt));
      // "Newest" means newest to the marketplace, matching the shelf's order;
      // GitHub's createdAt is the fallback for snapshots without firstSeenAt.
      else if (mode === "newest")
        by.sort(
          (a, b) => t(b.firstSeenAt ?? b.createdAt) - t(a.firstSeenAt ?? a.createdAt),
        );
      else if (mode === "name") by.sort((a, b) => (a.fullName || "").localeCompare(b.fullName || ""));
      else if (mode === "trending")
        // Qualified movers first by growth rate, then everything else by raw
        // weekly gain so the list stays fully ordered past the shelf criteria.
        by.sort(
          (a, b) =>
            (trendingScore(b) ?? -1) - (trendingScore(a) ?? -1) ||
            (b.starsDelta7d ?? 0) - (a.starsDelta7d ?? 0) ||
            (b.stars ?? 0) - (a.stars ?? 0),
        );
      else
        by.sort(
          (a, b) => (b.stars ?? 0) - (a.stars ?? 0) || t(b.pushedAt) - t(a.pushedAt),
        );
      return by;
    }

    function renderShelves() {
      const trending = repos
        .map((p) => [p, trendingScore(p)])
        .filter(([, score]) => score !== null)
        .sort((a, b) => b[1] - a[1])
        .slice(0, SHELF_SIZE)
        .map(([p]) => p);
      trendGrid.replaceChildren();
      if (trending.length >= SHELF_MIN_CARDS) {
        for (const p of trending) trendGrid.appendChild(buildTrendCard(p));
      }
      trendingShelf.hidden = trending.length < SHELF_MIN_CARDS;

      const fresh = repos
        .filter(isNewToHerd)
        .sort((a, b) => new Date(b.firstSeenAt).getTime() - new Date(a.firstSeenAt).getTime())
        .slice(0, SHELF_SIZE);
      const newRows = newShelf.querySelector(".new-rows");
      newRows.replaceChildren();
      for (const p of fresh) newRows.appendChild(buildRow(p, joinedBadge(p)));
      newShelf.hidden = fresh.length < SHELF_MIN_CARDS;

      const anyShelf = !trendingShelf.hidden || !newShelf.hidden;
      shelvesWrap.dataset.available = anyShelf ? "1" : "";
      updateShelfVisibility();
    }

    function updateShelfVisibility() {
      shelvesWrap.hidden = shelvesWrap.dataset.available !== "1";
    }

    function render() {
      const q = searchInput.value.trim().toLowerCase();
      const visible = sortRepos(repos.filter((p) => matches(p, q)), sortSelect.value);
      const shown = visible.slice(0, visibleLimit);

      grid.replaceChildren();
      for (const p of shown) grid.appendChild(buildCard(p));

      // Grow a page at a time, and name the real remainder on the last step so
      // the button never promises 24 and delivers 5.
      const remaining = visible.length - shown.length;
      moreButton.hidden = remaining === 0;
      if (remaining > 0) {
        moreButton.textContent = "show " + Math.min(PAGE_SIZE, remaining) + " more";
      }

      if (visible.length === 0) {
        statusEl.textContent = q
          ? "No plugins match your search."
          : "No plugins are listed yet.";
        statusEl.hidden = false;
      } else {
        statusEl.hidden = true;
      }
    }

    function setMeta(data) {
      const parts = [];
      const repoCount = (data && data.repositoryCount) ?? repos.length;
      const total =
        (data && data.pluginCount) ??
        repos.reduce((n, repo) => n + (manifestsOf(repo).length || 1), 0);
      parts.push(total + (total === 1 ? " plugin" : " plugins"));
      parts.push(repoCount + (repoCount === 1 ? " repository" : " repositories"));
      heroCountEl.textContent = total.toLocaleString("en-US");
      heroSummaryEl.textContent =
        (total === 1 ? "plugin" : "plugins") +
        " across " +
        repoCount.toLocaleString("en-US") +
        (repoCount === 1 ? " repository" : " repositories");
      const starsGained = repos.reduce(
        (n, repo) => n + Math.max(repo.starsDelta7d ?? 0, 0),
        0,
      );
      if (starsGained > 0) parts.push("+" + starsGained.toLocaleString("en-US") + " ★ this week");
      const joined = repos.filter((repo) => {
        const seen = daysSince(repo.firstSeenAt);
        return seen !== null && seen <= 7;
      }).length;
      if (joined > 0) parts.push(joined + " joined this week");
      const ago = timeAgo(data && data.generatedAt);
      if (ago) parts.push("updated " + ago);
      if (usingFallback) parts.push("preview data");
      metaEl.textContent = parts.join(" · ");
    }

    function apply(data) {
      repos = Array.isArray(data && data.plugins) ? data.plugins : [];
      setMeta(data);
      renderShelves();
      render();
    }

    function showError() {
      repos = [];
      metaEl.textContent = "";
      grid.replaceChildren();
      moreButton.hidden = true;
      statusEl.textContent =
        "The plugin marketplace is warming up. Check back in a few minutes.";
      statusEl.hidden = false;
    }

    async function load() {
      // Render the server-seeded data immediately (live in prod and dev; fixture
      // only as a dev offline fallback).
      if (initialData) {
        usingFallback = initialIsFixture;
        apply(initialData);
      }
      // Refresh from the same-origin function in production for the freshest snapshot.
      if (apiUrl) {
        try {
          const res = await fetch(apiUrl, { headers: { accept: "application/json" } });
          if (!res.ok) throw new Error("status " + res.status);
          usingFallback = false;
          apply(await res.json());
        } catch (err) {
          if (!initialData) showError();
        }
      } else if (!initialData) {
        showError();
      }
    }

    // A new query or order is a new list, so paging starts over.
    function resetAndRender() {
      visibleLimit = PAGE_SIZE;
      render();
    }

    searchInput.addEventListener("input", resetAndRender);
    sortSelect.addEventListener("change", resetAndRender);
    moreButton.addEventListener("click", () => {
      visibleLimit += PAGE_SIZE;
      render();
    });
    for (const button of document.querySelectorAll(".shelf-all")) {
      button.addEventListener("click", () => {
        sortSelect.value = button.dataset.sort;
        resetAndRender();
        grid.scrollIntoView({ behavior: "smooth", block: "start" });
      });
    }
    load();
  </script>

  <style is:global>
    .plugins-page {
      padding-bottom: 5rem;
      --plugins-clear-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M1.5 1.5l9 9M10.5 1.5l-9 9' stroke='black' stroke-width='1.6' stroke-linecap='round'/%3E%3C/svg%3E");
    }

    /* Reuse .section only for max-width centering, not its 5rem padding + top divider. */
    .plugins-page .section {
      padding-block: 0;
      border-top: none;
    }

    /* Everything below the hero sits inside the same horizontal gutter the
       hero uses, so text never touches the page's column lines. Selectors
       outrank site.css's `body.site .plugins-page .section` padding reset. */
    .plugins-page #plugins-shelves,
    body.site .plugins-page .section.plugins-results,
    body.site .plugins-page .section.plugins-publish {
      padding-inline: var(--gut);
    }

    .plugins-page .plugins-hero {
      padding-top: 3rem;
    }

    .plugins-hero h2 {
      margin: 0;
      color: var(--ink);
      font-family: var(--mono);
      font-weight: 400;
      letter-spacing: -0.04em;
      line-height: 1.05;
      font-size: clamp(2rem, 4vw, 3rem);
    }

    .plugins-lede {
      max-width: 640px;
      margin: 1.1rem 0 0;
      color: var(--ink-soft);
      font-size: clamp(1rem, 1.6vw, 1.15rem);
      line-height: 1.6;
    }

    .plugins-lede code,
    .plugins-trust code,
    .plugins-publish-card code {
      font-family: var(--mono);
      font-size: 0.85em;
      background: var(--bg-elevated);
      border: 1px solid var(--line);
      border-radius: var(--radius-sm);
      padding: 0.1em 0.35em;
      white-space: nowrap;
    }

    .plugins-trust {
      max-width: 640px;
      margin: 1.1rem 0 0;
      color: var(--muted);
      font-size: 0.85rem;
      line-height: 1.55;
    }

    .plugins-trust .status-dot {
      display: inline-block;
      margin-right: 0.4rem;
      vertical-align: middle;
    }

    .plugins-trust a {
      color: var(--accent);
      text-decoration: underline;
      text-underline-offset: 2px;
    }

    /* Controls */
    .plugins-controls {
      display: flex;
      flex-wrap: wrap;
      align-items: center;
      gap: 0.75rem;
      margin-top: 1.5rem;
    }

    .plugins-search-wrap {
      position: relative;
      flex: 1 1 18rem;
      display: flex;
      align-items: center;
    }

    .plugins-search-icon {
      position: absolute;
      left: 0.7rem;
      color: var(--muted-2);
      font-family: var(--mono);
      font-size: 0.9rem;
      pointer-events: none;
    }

    .plugins-search {
      width: 100%;
      padding: 0.6rem 0.8rem 0.6rem 1.7rem;
      background: var(--bg-elevated);
      border: 1px solid var(--line);
      border-radius: var(--radius-md);
      color: var(--ink);
      font-family: var(--mono);
      font-size: 0.85rem;
    }

    .plugins-search:focus {
      outline: none;
      border-color: var(--accent);
    }

    /* Chromium paints the native clear button in the OS accent colour, which
       belongs to no ground the site defines. Redraw it from our own tokens. */
    .plugins-search::-webkit-search-cancel-button {
      -webkit-appearance: none;
      appearance: none;
      width: 0.7rem;
      height: 0.7rem;
      cursor: pointer;
      background: var(--muted-2);
      -webkit-mask: var(--plugins-clear-icon) center / contain no-repeat;
      mask: var(--plugins-clear-icon) center / contain no-repeat;
    }

    .plugins-search::-webkit-search-cancel-button:hover {
      background: var(--ink);
    }

    .plugins-sort-wrap {
      display: inline-flex;
      align-items: center;
      gap: 0.5rem;
    }

    .plugins-sort-label {
      color: var(--muted);
      font-family: var(--mono);
      font-size: 0.78rem;
      text-transform: lowercase;
      letter-spacing: 0.06em;
    }

    .plugins-sort {
      padding: 0.55rem 0.7rem;
      background: var(--bg-elevated);
      border: 1px solid var(--line);
      border-radius: var(--radius-md);
      color: var(--ink);
      font-family: var(--mono);
      font-size: 0.82rem;
      cursor: pointer;
    }

    .plugins-sort:focus {
      outline: none;
      border-color: var(--accent);
    }

    .plugins-meta {
      margin: 1rem 0 0;
      color: var(--muted);
      font-family: var(--mono);
      font-size: 0.78rem;
      letter-spacing: 0.04em;
    }

    /* Shelves */
    #plugins-shelves {
      margin-top: 3.5rem;
    }

    body.site .plugins-page .section.plugins-shelf {
      margin-top: 4.5rem;
    }

    body.site .plugins-page #plugins-shelves > .section.plugins-shelf:first-child {
      margin-top: 0;
    }

    .shelf-head {
      display: flex;
      align-items: flex-end;
      justify-content: space-between;
      gap: 1rem;
      margin-bottom: 1.25rem;
    }

    /* Outranks style.css's `.section h2` mono heading rule. */
    body.site .plugins-page h2.shelf-title {
      margin: 0;
      color: var(--ink);
      font-family: var(--disp);
      font-weight: 800;
      font-size: 21px;
      letter-spacing: -0.03em;
      line-height: 1.2;
    }

    .shelf-sub {
      margin: 0.35rem 0 0;
      color: var(--muted);
      font-size: 0.82rem;
    }

    .shelf-all {
      flex: 0 0 auto;
      padding: 0;
      background: none;
      border: none;
      color: var(--accent);
      font-family: var(--mono);
      font-size: 0.82rem;
      cursor: pointer;
      text-decoration: underline;
      text-underline-offset: 3px;
    }

    .shelf-all:hover {
      color: var(--ink);
    }

    .plugin-badge {
      align-self: flex-start;
      padding: 0.15rem 0.5rem;
      background: color-mix(in srgb, var(--accent) 10%, transparent);
      border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
      border-radius: var(--radius-sm);
      color: var(--accent);
      font-family: var(--mono);
      font-size: 0.74rem;
      letter-spacing: 0.02em;
      white-space: nowrap;
    }

    .plugin-avatar {
      width: 1.5rem;
      height: 1.5rem;
      flex: 0 0 auto;
      border-radius: var(--radius-sm);
      background: var(--bg);
      border: 1px solid var(--line);
    }

    .trend-grid {
      display: grid;
      grid-template-columns: repeat(3, minmax(0, 1fr));
      gap: 0.85rem;
    }

    .plugin-trend-card {
      min-height: 0;
      gap: 0.65rem;
      padding: 1.1rem 1.2rem;
    }

    .trend-lead {
      display: flex;
      align-items: center;
      gap: 0.6rem;
      min-width: 0;
    }

    .new-rows {
      display: flex;
      flex-direction: column;
    }

    .plugin-row {
      display: flex;
      align-items: center;
      gap: 0.75rem;
      padding: 0.65rem 0.5rem;
      border-top: 1px solid var(--line);
      border-radius: var(--radius-sm);
      transition: background 0.12s ease;
      min-width: 0;
    }

    .plugin-row:last-child {
      border-bottom: 1px solid var(--line);
    }

    a.plugin-row:hover {
      background: var(--bg-elevated);
    }

    .plugin-row-body {
      display: flex;
      flex-direction: column;
      gap: 0.1rem;
      min-width: 0;
    }

    .plugin-row-body .plugin-title {
      font-size: 0.85rem;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }

    .plugin-row-desc {
      color: var(--muted);
      font-size: 0.78rem;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }

    .plugin-row-desc.is-empty {
      color: var(--muted-2);
      font-style: italic;
    }

    .plugin-row-side {
      display: flex;
      align-items: center;
      gap: 0.75rem;
      margin-left: auto;
      flex: 0 0 auto;
      color: var(--muted);
      font-family: var(--mono);
      font-size: 0.75rem;
    }

    .plugin-row-note {
      color: var(--accent);
      white-space: nowrap;
    }

    .plugins-browse-head {
      margin-bottom: 1rem;
      padding-top: 2.25rem;
      border-top: 1px solid var(--line);
    }

    .plugins-browse-head[hidden] {
      display: none;
    }

    @media (max-width: 56rem) {
      .trend-grid {
        grid-template-columns: repeat(2, minmax(0, 1fr));
      }
    }

    @media (max-width: 38rem) {
      .trend-grid {
        grid-template-columns: minmax(0, 1fr);
      }
    }

    /* Grid */
    body.site .plugins-page .section.plugins-results {
      margin-top: 4.5rem;
    }

    .plugins-results .plugins-grid {
      margin-top: 1.5rem;
    }

    .plugins-grid {
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
      gap: 0.85rem;
    }

    .plugin-card {
      display: flex;
      flex-direction: column;
      gap: 0.7rem;
      padding: 1.25rem;
      background: var(--bg-elevated);
      border: 1px solid var(--line);
      border-radius: var(--radius-md);
      transition: border-color 0.12s ease, transform 0.12s ease;
      min-height: 8.5rem;
    }

    a.plugin-card:hover {
      border-color: var(--accent);
      transform: translateY(-2px);
    }

    .plugin-card-head {
      display: flex;
      align-items: flex-start;
      justify-content: space-between;
      gap: 0.5rem;
    }

    .plugin-title {
      font-family: var(--mono);
      font-size: 0.95rem;
      line-height: 1.3;
      word-break: break-word;
    }

    .plugin-owner {
      color: var(--muted);
    }

    .plugin-name {
      color: var(--ink);
      font-weight: 600;
    }

    .plugin-go {
      color: var(--muted-2);
      font-family: var(--mono);
      flex: 0 0 auto;
      transition: color 0.12s ease;
    }

    a.plugin-card:hover .plugin-go {
      color: var(--accent);
    }

    .plugin-desc {
      margin: 0;
      color: var(--ink-soft);
      font-size: 0.88rem;
      line-height: 1.5;
      display: -webkit-box;
      -webkit-line-clamp: 2;
      -webkit-box-orient: vertical;
      overflow: hidden;
    }

    .plugin-desc.is-empty {
      color: var(--muted-2);
      font-style: italic;
    }

    .plugin-meta-row {
      display: flex;
      flex-wrap: wrap;
      align-items: center;
      gap: 0.9rem;
      margin-top: auto;
      color: var(--muted);
      font-family: var(--mono);
      font-size: 0.78rem;
    }

    .plugin-stat {
      display: inline-flex;
      align-items: center;
      gap: 0.35rem;
    }

    .plugin-star {
      color: var(--yellow, #d4a72c);
    }

    .plugin-lang-dot {
      width: 0.6rem;
      height: 0.6rem;
      border-radius: 50%;
      flex: 0 0 auto;
    }

    .plugin-updated {
      margin-left: auto;
      color: var(--muted-2);
    }

    /* What the repository ships. Sits below the repo stats, separated by a rule,
       so the repository stays the primary identity of the card. */
    .plugin-ships {
      display: flex;
      flex-direction: column;
      align-items: flex-start;
      gap: 0.45rem;
      padding-top: 0.7rem;
      border-top: 1px solid var(--line);
      font-family: var(--mono);
      font-size: 0.78rem;
      line-height: 1.5;
      min-width: 0;
    }

    .plugin-version {
      color: var(--muted-2);
    }

    .plugin-ship-count {
      flex: 0 0 auto;
      padding: 0.05rem 0.4rem;
      border: 1px solid var(--line);
      border-radius: var(--radius-sm);
      color: var(--muted-2);
    }

    .plugin-ship-list {
      align-self: stretch;
      min-width: 0;
      color: var(--muted);
      display: -webkit-box;
      -webkit-line-clamp: 2;
      -webkit-box-orient: vertical;
      overflow: hidden;
    }

    .plugins-status {
      padding: 3rem 1rem;
      text-align: center;
      color: var(--muted);
      font-family: var(--mono);
      font-size: 0.85rem;
    }

    .plugins-more {
      display: flex;
      margin: 2rem auto 0;
      cursor: pointer;
    }

    .plugins-more[hidden] {
      display: none;
    }

    /* Publish */
    .plugins-publish {
      margin-top: 3.5rem;
    }

    .plugins-publish-card {
      padding: 2rem;
      background: var(--bg-elevated);
      border: 1px solid var(--line);
      border-radius: var(--radius-lg);
    }

    .plugins-publish-card h3 {
      margin: 0.25rem 0 0;
      color: var(--ink);
      font-family: var(--mono);
      font-weight: 400;
      font-size: 1.4rem;
      letter-spacing: -0.02em;
    }

    .plugins-publish-card p {
      max-width: 620px;
      margin: 0.8rem 0 0;
      color: var(--ink-soft);
      line-height: 1.6;
    }

    .plugins-publish-actions {
      display: flex;
      flex-wrap: wrap;
      gap: 0.6rem;
      margin-top: 1.5rem;
    }

    @media (max-width: 38rem) {
      .plugins-meta {
        margin-left: 0;
        width: 100%;
      }
    }
  </style>
</SiteLayout>
