aboutsummaryrefslogtreecommitdiff
path: root/website/src/pages/plugins.astro
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-09-01 14:48:45 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-09-01 14:48:45 +0300
commit8a6d697308162874a236c84aac0b0f8c7ac01f83 (patch)
treeed7dc7e8c6f50d66bde721fae78576aede929f66 /website/src/pages/plugins.astro
parentb54a78aa510b87d1e3b56c619b72eaeb59e4a2a1 (diff)
refactor: separate website presentation from runtime
Diffstat (limited to 'website/src/pages/plugins.astro')
-rw-r--r--website/src/pages/plugins.astro1159
1 files changed, 0 insertions, 1159 deletions
diff --git a/website/src/pages/plugins.astro b/website/src/pages/plugins.astro
deleted file mode 100644
index 57b189ea..00000000
--- a/website/src/pages/plugins.astro
+++ /dev/null
@@ -1,1159 +0,0 @@
----
-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>