feat(plugin-catalog): shelve the catalog by category (Memory, Desktop, Platforms, …)

The catalog page was one undifferentiated grid filtered only by tier, so a
memory provider sat between two Desktop panes. Entries now carry an optional
``category`` (memory | desktop | platform | web | tools | voice | automation |
models | other, default other) that the loader, the admission validator and
the site extractor all understand.

/docs/plugins renders one shelf per category in browse mode, a category pill
row under the tier pills, a clickable category chip on every card, and a
results bar (active category, count, clear) when a filter or search flattens
the view. ``hermes plugins catalog`` gains a Category column and groups by it.
All 18 shipped entries are categorised. Unknown categories fail admission
(same contract as tier) so a typo cannot create a phantom shelf.
This commit is contained in:
teknium1
2026-09-14 18:41:29 -07:00
committed by Teknium
parent f5a457ad5b
commit 55dbd7f6e1
29 changed files with 374 additions and 30 deletions
+9 -2
View File
@@ -27,6 +27,8 @@ import yaml
logger = logging.getLogger(__name__)
CATALOG_TIERS = ("official", "community")
# Browse taxonomy for the catalog page / picker. Entries without one land in "other".
CATALOG_CATEGORIES = ("memory", "desktop", "platform", "web", "tools", "voice", "automation", "models", "other")
LIVE_CATALOG_URL = "https://hermes-agent.nousresearch.com/docs/api/plugin-catalog.json"
LIVE_CATALOG_TTL_SECONDS = 6 * 60 * 60
_REQUEST_TIMEOUT = 5.0
@@ -60,6 +62,7 @@ class PluginCatalogEntry:
description: str
maintainer: str
tier: str = "community"
category: str = "other"
requires_hermes: str = ""
subdir: str = ""
docs_url: str = ""
@@ -75,7 +78,8 @@ class PluginCatalogEntry:
caps = self.capabilities
return {
"name": self.name, "repo": self.repo, "sha": self.sha, "description": self.description,
"maintainer": self.maintainer, "tier": self.tier, "requires_hermes": self.requires_hermes,
"maintainer": self.maintainer, "tier": self.tier, "category": self.category,
"requires_hermes": self.requires_hermes,
"subdir": self.subdir, "docs_url": self.docs_url, "platforms": list(self.platforms),
"capabilities": {
"provides_tools": list(caps.provides_tools), "provides_hooks": list(caps.provides_hooks),
@@ -104,11 +108,14 @@ def entry_from_mapping(data: Any, label: str) -> Optional[PluginCatalogEntry]:
repo = str(data.get("repo") or "")
sha = str(data.get("sha") or "").strip().lower()
tier = str(data.get("tier") or "community")
category = str(data.get("category") or "other")
problem = (
f"invalid name {name!r} (must match [a-z0-9_-]{{1,64}})" if not _NAME_RE.match(name)
else f"repo must be an https:// URL (got {repo!r})" if not repo.startswith("https://")
else f"sha must be a full 40-character hex commit SHA (got {data.get('sha')!r})" if not _SHA_RE.match(sha)
else f"tier must be one of {'/'.join(CATALOG_TIERS)} (got {tier!r})" if tier not in CATALOG_TIERS
else f"category must be one of {'/'.join(CATALOG_CATEGORIES)} (got {category!r})"
if category not in CATALOG_CATEGORIES
else None)
if problem:
logger.warning("Plugin catalog: %s: %s", label, problem)
@@ -118,7 +125,7 @@ def entry_from_mapping(data: Any, label: str) -> Optional[PluginCatalogEntry]:
return PluginCatalogEntry(
name=name, repo=repo, sha=sha,
description=str(data.get("description") or "").strip(),
maintainer=str(data.get("maintainer") or "").strip(), tier=tier,
maintainer=str(data.get("maintainer") or "").strip(), tier=tier, category=category,
requires_hermes=str(data.get("requires_hermes") or "").strip(),
subdir=str(data.get("subdir") or "").strip(), docs_url=str(data.get("docs_url") or "").strip(),
platforms=_str_list(data.get("platforms")),
+4 -4
View File
@@ -162,12 +162,12 @@ def _capability_counts(entry: PluginCatalogEntry) -> str:
def _render_entries(entries: List[PluginCatalogEntry], console) -> None:
from hermes_cli.plugins_cmd import _table
table = _table(((("Name", "bold")), ("Tier", None), ("Description", None), ("Pinned", "dim"),
("Capabilities", "dim")), title="Hermes Plugin Catalog (curated)")
for e in entries:
table = _table(((("Name", "bold")), ("Category", None), ("Tier", None), ("Description", None),
("Pinned", "dim"), ("Capabilities", "dim")), title="Hermes Plugin Catalog (curated)")
for e in sorted(entries, key=lambda e: (e.category, e.tier != "official", e.name)):
tier = "[cyan]official[/cyan]" if e.tier == "official" else "[magenta]community[/magenta]"
desc = e.description if len(e.description) <= 60 else e.description[:57] + "..."
table.add_row(e.name, tier, desc, e.sha[:8], _capability_counts(e))
table.add_row(e.name, e.category, tier, desc, e.sha[:8], _capability_counts(e))
console.print()
console.print(table)
console.print()
+2
View File
@@ -44,6 +44,8 @@ subdir: "" # optional path within the repo
description: One-line description.
maintainer: OwnerName
tier: official # official | community (default community)
category: memory # memory | desktop | platform | web | tools | voice | automation | models | other
# (default other) — the shelf the entry sits on at /docs/plugins
requires_hermes: ">=0.19" # optional
docs_url: "" # optional
platforms: [] # optional, e.g. [linux, macos]; empty = all
+1
View File
@@ -5,6 +5,7 @@ description: Wake Hermes when allowlisted Herdr panes need reconciliation, witho
pane work.
maintainer: chris-yyau
tier: community
category: automation
docs_url: https://github.com/chris-yyau/hermes-herdr-auto-reconcile
platforms: []
capabilities:
+1
View File
@@ -4,6 +4,7 @@ sha: 9be4b850c874cebaa18b0077cdc5164b5e7b953b
description: Inspect session costs and usage in Hermes Desktop.
maintainer: Adolanium
tier: community
category: desktop
docs_url: https://github.com/Adolanium/hermes-ledgerline#readme
capabilities:
provides_tools: []
+1
View File
@@ -4,6 +4,7 @@ sha: 94f99436786e6fbbfcce161635d11c67bf094c8e
description: Browse Nous Portal model prices and account balances in Hermes Desktop.
maintainer: Adolanium
tier: community
category: desktop
docs_url: https://github.com/Adolanium/hermes-nous-prices#readme
capabilities:
provides_tools: []
+1
View File
@@ -4,6 +4,7 @@ sha: e1e0255a48fe7a7549d8d0a5ef0747a41a0fb7e7
description: View and manage Bot Mode agents in a shared office in Hermes Desktop.
maintainer: Adolanium
tier: community
category: desktop
docs_url: https://github.com/Adolanium/hermes-office#readme
capabilities:
provides_tools: []
@@ -4,6 +4,7 @@ sha: 5b9c3257b464c0f926d4355149a8aed9c8f307b4
description: Switch Hermes browser tools between local and remote Chrome/Edge profiles via CDP.
maintainer: anpicasso
tier: community
category: web
docs_url: https://github.com/anpicasso/hermes-plugin-chrome-profiles
platforms: []
capabilities:
+1
View File
@@ -5,6 +5,7 @@ description: 'NetBox change management: query DCIM/IPAM objects, build a reviewe
with a journal and roll back.'
maintainer: andrewvieyra
tier: community
category: tools
docs_url: https://github.com/andrewvieyra/hermes-plugin-netbox
platforms: []
capabilities:
+1
View File
@@ -4,6 +4,7 @@ sha: 3be783290fd66c6070af8eaf7a2286c7ef5e71c5
description: Track subscription quotas and reset times in Hermes Desktop.
maintainer: Adolanium
tier: community
category: desktop
docs_url: https://github.com/Adolanium/hermes-resetwatch#readme
capabilities:
provides_tools: []
+1
View File
@@ -4,6 +4,7 @@ sha: 8053056094f8a232babe029e2336cb8118d8ba7a
description: Read RSS and Atom feeds and discuss articles in Hermes Desktop.
maintainer: Adolanium
tier: community
category: desktop
docs_url: https://github.com/Adolanium/hermes-rss#readme
capabilities:
provides_tools: []
@@ -5,6 +5,7 @@ description: Local-only iMessage platform plugin backed by a launchd-supervised
over a same-user Unix socket.
maintainer: boobutler
tier: community
category: platform
docs_url: https://github.com/boobutler/hermes-rustpush-imessage
platforms: []
capabilities:
+1
View File
@@ -5,6 +5,7 @@ description: 'Snapcompact context engine: archives conversation history as dense
vision models read back at ~1/3 the token cost.'
maintainer: vimona3ds
tier: community
category: memory
docs_url: https://github.com/vimona3ds/hermes-snapcompact
platforms: []
capabilities:
+1
View File
@@ -4,6 +4,7 @@ sha: 02c6b34ee604a30827df25a082cba03fbf6fdf17
description: Manage SSH connections and remote workspaces in Hermes Desktop.
maintainer: Adolanium
tier: community
category: desktop
docs_url: https://github.com/Adolanium/hermes-ssh#readme
capabilities:
provides_tools: []
+1
View File
@@ -4,6 +4,7 @@ sha: 6dad1d5aac443a196f7ba83736a1f4c9e3a75048
description: Browse Tailscale devices and connect to remote Hermes instances in Hermes Desktop.
maintainer: Adolanium
tier: community
category: desktop
docs_url: https://github.com/Adolanium/hermes-tailscale#readme
capabilities:
provides_tools: []
@@ -5,6 +5,7 @@ description: 'Observe-with-approval Telegram Business Mode (secretary bot): ever
needs owner approval before it is sent.'
maintainer: NousResearch
tier: official
category: platform
docs_url: https://github.com/NousResearch/hermes-telegram-business
platforms: []
capabilities:
+1
View File
@@ -4,6 +4,7 @@ sha: 43658bf3c883178bf035d2163f7f541500d17407
description: Run the Hermes terminal interface in a Hermes Desktop workspace tab.
maintainer: Adolanium
tier: community
category: desktop
docs_url: https://github.com/Adolanium/hermes-terminal#readme
capabilities:
provides_tools: []
+1
View File
@@ -5,6 +5,7 @@ description: 'Typed adapter for the reproducible JACKAL v1.7.3 release: 41 catal
tools (exact, numerical, Lean-checked bounded lanes, claim replay).'
maintainer: AnubisQuantumCipher
tier: community
category: tools
docs_url: https://github.com/AnubisQuantumCipher/hermes-jackal-verified
platforms: []
capabilities:
+1
View File
@@ -4,6 +4,7 @@ sha: c7d56211420438bd300c3526b7536f55aafb0fce
description: Shodan tools with complete capability declarations.
maintainer: Adolanium
tier: community
category: tools
docs_url: https://github.com/Adolanium/hermes-plugin-shodan#readme
capabilities:
provides_tools:
+1
View File
@@ -7,6 +7,7 @@ description: 'Scan code (SAST), dependencies, container images, IaC and SBOMs wi
needs Node.js/npm on PATH and a free Snyk account (browser login via the snyk_auth tool).'
maintainer: NousResearch
tier: official
category: tools
docs_url: https://github.com/NousResearch/hermes-plugin-snyk
platforms: []
capabilities:
+1
View File
@@ -6,6 +6,7 @@ description: 'Drive a live TouchDesigner session through the twozero MCP server
(mcp.json + skills/); TouchDesigner itself runs on macOS/Windows.'
maintainer: NousResearch
tier: official
category: tools
docs_url: https://github.com/NousResearch/hermes-plugin-touchdesigner
platforms: []
capabilities:
+6
View File
@@ -42,6 +42,7 @@ except ImportError: # pragma: no cover - dependency guidance only
NAME_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
SHA_RE = re.compile(r"^[0-9a-f]{40}$")
TIERS = ("official", "community")
CATEGORIES = ("memory", "desktop", "platform", "web", "tools", "voice", "automation", "models", "other")
PLATFORMS = ("linux", "macos", "windows")
CAPABILITY_KEYS = (
"provides_tools",
@@ -59,6 +60,7 @@ KNOWN_KEYS = {
"description",
"maintainer",
"tier",
"category",
"requires_hermes",
"docs_url",
"platforms",
@@ -125,6 +127,10 @@ def validate_entry(data: object) -> tuple[list[str], list[str]]:
if tier not in TIERS:
errors.append(f"tier {tier!r} must be one of {list(TIERS)}")
category = data.get("category", "other")
if category not in CATEGORIES:
errors.append(f"category {category!r} must be one of {list(CATEGORIES)}")
if "requires_hermes" in data:
_check_requires_hermes(data["requires_hermes"], errors)
+12
View File
@@ -30,6 +30,18 @@ def test_shipped_catalog_entries_are_all_valid_and_pinned():
assert all(e.install_identifier.startswith(e.repo) for e in entries)
def test_category_defaults_to_other_and_unknown_category_is_rejected(tmp_path):
"""``category`` is the browse shelf: absent → ``other``; a value outside CATALOG_CATEGORIES is an
invalid entry (skipped with a warning) so a typo cannot create a phantom shelf on the site."""
(tmp_path / "a.yaml").write_text(yaml.safe_dump(_entry("no-cat")))
(tmp_path / "b.yaml").write_text(yaml.safe_dump(_entry("mem", category="memory")))
(tmp_path / "c.yaml").write_text(yaml.safe_dump(_entry("typo", category="memmory")))
entries = {e.name: e for e in pc.load_catalog(tmp_path)}
assert set(entries) == {"no-cat", "mem"}
assert entries["no-cat"].category == "other" and entries["mem"].category == "memory"
assert entries["mem"].to_dict()["category"] == "memory"
def test_invalid_entries_are_skipped_not_raised(tmp_path):
(tmp_path / "a.yaml").write_text(yaml.safe_dump(_entry("ok")))
(tmp_path / "b.yaml").write_text(yaml.safe_dump(_entry("short-sha", sha="abc123")))
@@ -88,6 +88,15 @@ def _expect_error(tmp_path, mutation: dict, expected_substring: str, drop: str =
assert "entry.yaml" in combined, combined
def test_unknown_category_fails(tmp_path):
path = write_entry(tmp_path, {**VALID_ENTRY, "category": "memmory"})
result = run_validator(str(path))
assert result.returncode != 0
assert "category" in result.stdout + result.stderr
path = write_entry(tmp_path, {**VALID_ENTRY, "category": "memory"}, "ok.yaml")
assert run_validator(str(path)).returncode == 0
def test_bad_name_fails(tmp_path):
_expect_error(tmp_path, {"name": "Bad Name!"}, "name")
+4 -2
View File
@@ -140,8 +140,8 @@ def test_unknown_tier_normalizes_to_community(mod, tmp_path):
def test_main_writes_catalog_and_meta(mod, tmp_path):
catalog = tmp_path / "plugin-catalog"
catalog.mkdir()
_write_entry(catalog, "alpha", tier="official")
_write_entry(catalog, "beta")
_write_entry(catalog, "alpha", tier="official", category="memory")
_write_entry(catalog, "beta") # no category → "other" shelf
(catalog / "removed.yaml").write_text(
"removed:\n - name: gone\n", encoding="utf-8"
)
@@ -155,6 +155,8 @@ def test_main_writes_catalog_and_meta(mod, tmp_path):
assert [p["name"] for p in plugins] == ["alpha", "beta"]
assert meta["total"] == 2
assert meta["byTier"] == {"official": 1, "community": 1}
assert {p["name"]: p["category"] for p in plugins} == {"alpha": "memory", "beta": "other"}
assert meta["byCategory"] == {"memory": 1, "other": 1}
assert meta["removedCount"] == 1
assert meta["generatedAt"]
# The live-refresh document consumed by installed clients: loader-schema entries + the kill list.
@@ -14,9 +14,10 @@ can install by name with a single command:
hermes plugins install <name>
```
Browse it visually at **[/docs/plugins](/plugins)** — search, tier filters
(Official / Community), capability chips, and copyable install commands for
every entry.
Browse it visually at **[/docs/plugins](/plugins)** — entries are shelved by
category (Memory, Desktop, Platforms, Web & Browser, Tools, Voice, Automation,
Models), with search, tier filters (Official / Community), capability chips, and
copyable install commands for every entry.
The catalog complements — it does not replace — the existing
[plugin system](plugins.md). Anything you can install from the catalog is a
@@ -35,6 +36,7 @@ directory of the hermes-agent repository, declaring:
| `repo` | The plugin's public git repository |
| `sha` | The **exact 40-hex commit** that was reviewed — installs check out this pin, not a branch tip |
| `tier` | `official` (maintained by NousResearch) or `community` |
| `category` | Browse shelf: `memory`, `desktop`, `platform`, `web`, `tools`, `voice`, `automation`, `models` or `other` (default) |
| `maintainer` | Who owns the plugin |
| `capabilities` | Declared tools, hooks, middleware, and required env vars |
| `requires_hermes` | Minimum Hermes version, e.g. `>=0.19` (optional) |
+8
View File
@@ -40,6 +40,7 @@ DEFAULT_CATALOG_DIR = REPO_ROOT / "plugin-catalog"
DEFAULT_OUTPUT_DIR = REPO_ROOT / "website" / "static" / "api"
CATALOG_TIERS = ("official", "community")
CATALOG_CATEGORIES = ("memory", "desktop", "platform", "web", "tools", "voice", "automation", "models", "other")
SHA_RE = re.compile(r"^[0-9a-f]{40}$")
@@ -106,6 +107,10 @@ def load_catalog_entries(catalog_dir: Path) -> list[dict]:
if tier not in CATALOG_TIERS:
_log(f"{path.name} ({name}): unknown tier {tier!r}, treating as community")
tier = "community"
category = str(raw.get("category") or "other").strip().lower()
if category not in CATALOG_CATEGORIES:
_log(f"{path.name} ({name}): unknown category {category!r}, treating as other")
category = "other"
entries.append({
"name": name,
@@ -114,6 +119,7 @@ def load_catalog_entries(catalog_dir: Path) -> list[dict]:
"sha": sha,
"shaShort": sha[:7],
"tier": tier,
"category": category,
"maintainer": str(raw.get("maintainer") or "").strip(),
"subdir": str(raw.get("subdir") or "").strip(),
"requiresHermes": str(raw.get("requires_hermes") or "").strip(),
@@ -173,10 +179,12 @@ def main(catalog_dir: Path = DEFAULT_CATALOG_DIR, output_dir: Path = DEFAULT_OUT
removed_count = count_removed(catalog_dir)
by_tier = Counter(e["tier"] for e in entries)
by_category = Counter(e["category"] for e in entries)
meta = {
"generatedAt": datetime.now(timezone.utc).isoformat(),
"total": len(entries),
"byTier": {tier: by_tier.get(tier, 0) for tier in CATALOG_TIERS},
"byCategory": {c: by_category.get(c, 0) for c in CATALOG_CATEGORIES if by_category.get(c)},
"removedCount": removed_count,
}
+160 -19
View File
@@ -17,6 +17,7 @@ interface CatalogPlugin {
sha: string;
shaShort: string;
tier: string;
category: string;
maintainer: string;
subdir?: string;
requiresHermes?: string;
@@ -32,6 +33,7 @@ interface CatalogMeta {
generatedAt?: string;
total?: number;
byTier?: Record<string, number>;
byCategory?: Record<string, number>;
removedCount?: number;
}
@@ -65,6 +67,22 @@ const TIER_CONFIG: Record<
const TIER_ORDER = ["all", "official", "community"];
// Browse taxonomy. Order here is the order of the filter pills and of the
// grouped sections; keep it in sync with CATALOG_CATEGORIES in
// hermes_cli/plugin_catalog.py and website/scripts/extract-plugins.py.
const CATEGORY_CONFIG: Record<string, { label: string; icon: string; blurb: string }> = {
memory: { label: "Memory", icon: "\u{1F9E0}", blurb: "Memory providers and context engines" },
desktop: { label: "Desktop", icon: "\u{1F5A5}\u{FE0F}", blurb: "Panes, tabs and views for Hermes Desktop" },
platform: { label: "Platforms", icon: "\u{1F4AC}", blurb: "Messaging and channel adapters" },
web: { label: "Web & Browser", icon: "\u{1F310}", blurb: "Search backends, extraction and browser control" },
tools: { label: "Tools", icon: "\u{1F6E0}\u{FE0F}", blurb: "New tools the agent can call" },
voice: { label: "Voice", icon: "\u{1F399}\u{FE0F}", blurb: "Speech, TTS and realtime audio" },
automation: { label: "Automation", icon: "\u{23F1}\u{FE0F}", blurb: "Hooks, wake triggers and session automation" },
models: { label: "Models", icon: "\u{2728}", blurb: "Model and inference providers" },
other: { label: "Other", icon: "\u{1F4E6}", blurb: "Everything else" },
};
const CATEGORY_ORDER = Object.keys(CATEGORY_CONFIG);
function formatRelativeTime(iso?: string): string | null {
if (!iso) return null;
const then = new Date(iso).getTime();
@@ -142,6 +160,7 @@ function PluginCard({
expanded,
onToggle,
onPick,
onCategoryClick,
style,
}: {
plugin: CatalogPlugin;
@@ -150,9 +169,11 @@ function PluginCard({
onToggle: () => void;
/** Picker embed mode: render "+ Add to this Agent" and call this. */
onPick?: (plugin: CatalogPlugin) => void;
onCategoryClick?: (category: string) => void;
style?: React.CSSProperties;
}) {
const tier = TIER_CONFIG[plugin.tier] || TIER_CONFIG.community;
const category = CATEGORY_CONFIG[plugin.category] || CATEGORY_CONFIG.other;
const caps = plugin.capabilities || {};
const toolCount = caps.providesTools?.length || 0;
const hookCount = caps.providesHooks?.length || 0;
@@ -169,7 +190,7 @@ function PluginCard({
<div className={styles.cardInner}>
<div className={styles.cardTop}>
<span className={styles.cardIcon}>{"\u{1F50C}"}</span>
<span className={styles.cardIcon} title={category.label}>{category.icon}</span>
<div className={styles.cardTitleGroup}>
<h3 className={styles.cardTitle}>{highlightMatch(plugin.name, query)}</h3>
<span
@@ -190,6 +211,16 @@ function PluginCard({
</p>
<div className={styles.cardMeta}>
<button
className={styles.categoryChip}
onClick={(e) => {
e.stopPropagation();
onCategoryClick?.(plugin.category);
}}
title={`Filter by ${category.label}`}
>
{category.label}
</button>
{toolCount > 0 && (
<span className={styles.capChip}>
{toolCount} tool{toolCount === 1 ? "" : "s"}
@@ -322,6 +353,8 @@ function buildSearchHaystack(p: CatalogPlugin): string {
p.description,
p.maintainer,
p.tier,
p.category,
CATEGORY_CONFIG[p.category]?.label,
...(p.capabilities?.providesTools || []),
...(p.capabilities?.providesHooks || []),
...(p.capabilities?.requiresEnv || []),
@@ -368,6 +401,7 @@ export default function PluginCatalogPage() {
const [search, setSearch] = useState("");
const [tierFilter, setTierFilter] = useState("all");
const [categoryFilter, setCategoryFilter] = useState("all");
const [expandedCard, setExpandedCard] = useState<string | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
@@ -418,22 +452,68 @@ export default function PluginCatalogPage() {
const q = search.toLowerCase().trim();
return allPlugins.filter((p) => {
if (tierFilter !== "all" && p.tier !== tierFilter) return false;
if (categoryFilter !== "all" && p.category !== categoryFilter) return false;
if (q) return (p._search || "").includes(q);
return true;
});
}, [search, tierFilter, allPlugins]);
}, [search, tierFilter, categoryFilter, allPlugins]);
// Browse mode (no search, no category picked): render one section per
// category so Memory, Desktop, Platforms… read as distinct shelves rather
// than one undifferentiated wall. Filtering or searching flattens to a grid.
const grouped = useMemo(() => {
if (search.trim() || categoryFilter !== "all") return null;
const buckets = new Map<string, CatalogPlugin[]>();
for (const p of filtered) {
const key = CATEGORY_CONFIG[p.category] ? p.category : "other";
(buckets.get(key) ?? buckets.set(key, []).get(key)!).push(p);
}
return CATEGORY_ORDER.filter((c) => buckets.has(c)).map((c) => [c, buckets.get(c)!] as const);
}, [filtered, search, categoryFilter]);
const categoryCounts = useMemo(() => {
const counts: Record<string, number> = {};
for (const p of allPlugins) {
if (tierFilter !== "all" && p.tier !== tierFilter) continue;
const key = CATEGORY_CONFIG[p.category] ? p.category : "other";
counts[key] = (counts[key] || 0) + 1;
}
return counts;
}, [allPlugins, tierFilter]);
useEffect(() => {
setExpandedCard(null);
}, [search, tierFilter]);
}, [search, tierFilter, categoryFilter]);
const clearAll = useCallback(() => {
setSearch("");
setTierFilter("all");
setCategoryFilter("all");
}, []);
const pickCategory = useCallback((c: string) => {
setCategoryFilter((cur) => (cur === c ? "all" : c));
setSearch("");
}, []);
const catalogEmpty = data !== null && allPlugins.length === 0;
const renderCard = (plugin: CatalogPlugin, i: number) => {
const key = `${plugin.tier}-${plugin.name}`;
return (
<PluginCard
key={key}
plugin={plugin}
query={search}
expanded={expandedCard === key}
onToggle={() => setExpandedCard(expandedCard === key ? null : key)}
onPick={pickerMode ? pickPlugin : undefined}
onCategoryClick={pickCategory}
style={{ animationDelay: `${Math.min(i, 20) * 25}ms` }}
/>
);
};
return (
<Layout
title="Plugin Catalog"
@@ -482,6 +562,11 @@ export default function PluginCatalogPage() {
label="Community"
color="#94a3b8"
/>
<StatCard
value={Object.keys(categoryCounts).length}
label="Categories"
color="#7dd3fc"
/>
<StatCard value={meta.removedCount ?? 0} label="Removed" color="#f87171" />
</div>
)}
@@ -554,6 +639,34 @@ export default function PluginCatalogPage() {
);
})}
</div>
<div className={styles.categoryPills} role="tablist" aria-label="Plugin categories">
<button
className={`${styles.categoryBtn} ${categoryFilter === "all" ? styles.categoryBtnActive : ""}`}
onClick={() => setCategoryFilter("all")}
role="tab"
aria-selected={categoryFilter === "all"}
>
All categories
</button>
{CATEGORY_ORDER.filter((c) => categoryCounts[c]).map((c) => {
const conf = CATEGORY_CONFIG[c];
const active = categoryFilter === c;
return (
<button
key={c}
className={`${styles.categoryBtn} ${active ? styles.categoryBtnActive : ""}`}
onClick={() => pickCategory(c)}
role="tab"
aria-selected={active}
title={conf.blurb}
>
<span aria-hidden="true">{conf.icon}</span> {conf.label}
<span className={styles.tierCount}>{categoryCounts[c]}</span>
</button>
);
})}
</div>
</div>
)}
@@ -586,23 +699,51 @@ export default function PluginCatalogPage() {
</Link>
</div>
</div>
) : filtered.length > 0 && grouped ? (
grouped.map(([cat, plugins]) => {
const conf = CATEGORY_CONFIG[cat];
return (
<section key={cat} className={styles.categorySection} aria-labelledby={`cat-${cat}`}>
<header className={styles.categoryHeader}>
<h2 id={`cat-${cat}`} className={styles.categoryTitle}>
<span aria-hidden="true">{conf.icon}</span> {conf.label}
<span className={styles.tierCount}>{plugins.length}</span>
</h2>
<p className={styles.categoryBlurb}>{conf.blurb}</p>
<button className={styles.categoryViewAll} onClick={() => pickCategory(cat)}>
View only {conf.label} →
</button>
</header>
<div className={styles.grid}>
{plugins.map((plugin, i) => renderCard(plugin, i))}
</div>
</section>
);
})
) : filtered.length > 0 ? (
<div className={styles.grid}>
{filtered.map((plugin, i) => {
const key = `${plugin.tier}-${plugin.name}`;
return (
<PluginCard
key={key}
plugin={plugin}
query={search}
expanded={expandedCard === key}
onToggle={() => setExpandedCard(expandedCard === key ? null : key)}
onPick={pickerMode ? pickPlugin : undefined}
style={{ animationDelay: `${Math.min(i, 20) * 25}ms` }}
/>
);
})}
</div>
<>
<div className={styles.resultsBar} role="status">
{categoryFilter !== "all" && CATEGORY_CONFIG[categoryFilter] && (
<span>
<span aria-hidden="true">{CATEGORY_CONFIG[categoryFilter].icon}</span>{" "}
<strong>{CATEGORY_CONFIG[categoryFilter].label}</strong>
<span style={{ opacity: 0.7 }}> · {CATEGORY_CONFIG[categoryFilter].blurb}</span>
</span>
)}
{search.trim() && (
<span>
Results for <strong>“{search.trim()}”</strong>
</span>
)}
<span>
{filtered.length} plugin{filtered.length === 1 ? "" : "s"}
</span>
<button className={styles.resultsClear} onClick={clearAll}>
Clear filters
</button>
</div>
<div className={styles.grid}>{filtered.map((plugin, i) => renderCard(plugin, i))}</div>
</>
) : (
<div className={styles.empty}>
<div className={styles.emptyIcon}>{"\u{1F50D}"}</div>
+137
View File
@@ -244,6 +244,143 @@
background: rgba(255, 255, 255, 0.08);
}
/* ── Category pills + grouped sections ─────────────────────────────────── */
.categoryPills {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
justify-content: center;
width: 100%;
padding-top: 0.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.categoryBtn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.7rem;
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 20px;
background: transparent;
color: var(--ifm-color-emphasis-600);
font-size: 0.8rem;
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.categoryBtn:hover {
border-color: rgba(255, 255, 255, 0.15);
color: var(--ifm-font-color-base);
}
.categoryBtnActive {
border-color: rgba(125, 211, 252, 0.7);
background: rgba(125, 211, 252, 0.14);
color: #bae6fd;
font-weight: 600;
box-shadow: 0 0 0 1px rgba(125, 211, 252, 0.35), 0 0 14px rgba(125, 211, 252, 0.18);
}
.categoryBtnActive .tierCount {
background: rgba(125, 211, 252, 0.2);
}
/* Header shown above the flat grid when a category filter or search is active */
.resultsBar {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.9rem;
padding-bottom: 0.6rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
font-size: 0.9rem;
color: var(--ifm-color-emphasis-700);
}
.resultsBar strong {
color: var(--ifm-font-color-base);
font-weight: 650;
}
.resultsClear {
margin-left: auto;
border: 1px solid rgba(255, 255, 255, 0.1);
background: transparent;
color: var(--ifm-color-emphasis-700);
border-radius: 14px;
padding: 0.2rem 0.7rem;
font-size: 0.8rem;
cursor: pointer;
}
.resultsClear:hover {
border-color: rgba(255, 255, 255, 0.2);
color: var(--ifm-font-color-base);
}
.categorySection {
margin-bottom: 2.25rem;
}
.categoryHeader {
display: grid;
grid-template-columns: 1fr auto;
grid-template-areas: "title view" "blurb view";
align-items: center;
gap: 0.1rem 1rem;
margin-bottom: 0.9rem;
padding-bottom: 0.6rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.categoryTitle {
grid-area: title;
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
font-size: 1.15rem;
font-weight: 650;
letter-spacing: -0.01em;
}
.categoryBlurb {
grid-area: blurb;
margin: 0;
font-size: 0.85rem;
color: var(--ifm-color-emphasis-600);
}
.categoryViewAll {
grid-area: view;
border: none;
background: transparent;
color: #7dd3fc;
font-size: 0.82rem;
cursor: pointer;
padding: 0.25rem 0;
white-space: nowrap;
}
.categoryViewAll:hover {
text-decoration: underline;
}
.categoryChip {
border: 1px solid rgba(125, 211, 252, 0.25);
background: rgba(125, 211, 252, 0.07);
color: #7dd3fc;
border-radius: 6px;
padding: 0.1rem 0.45rem;
font-size: 0.72rem;
cursor: pointer;
}
.categoryChip:hover {
background: rgba(125, 211, 252, 0.14);
}
.main {
max-width: 1200px;
margin: 0 auto;