agnes-the-ai-analyst/app/web/templates/marketplace.html
minasarustamyan dc5e0e0d11
Marketplace UX overhaul: rich plugin/skill/agent detail + filename rename (#251)
* Rename agnes-metadata.json to marketplace-metadata.json

Curated marketplace enrichment file (.claude-plugin/agnes-metadata.json)
becomes marketplace-metadata.json. Clean cut, no fallback — curators of
upstream marketplace repos must rename the file on their side.

Python API renames mirror the file rename: read_agnes_metadata →
read_marketplace_metadata, AGNES_METADATA_REL → MARKETPLACE_METADATA_REL,
AGNES_METADATA_MAX_BYTES → MARKETPLACE_METADATA_MAX_BYTES. Synth Claude
Code marketplace strip rule (.agnes/** + the metadata file) follows the
new filename.

* Marketplace detail polish: window cover + 715:310 aspect + helper alignment

- Plugin & item (skill/agent) detail hero: 160x160 square cover replaced
  with a macOS-style window frame (3 traffic-light dots + titlebar label
  showing the entity name). Body is constrained to 715:310 so curator-
  uploaded covers no longer crop to a square. Window is 380px wide; meta
  column and absolutely-positioned top-right install/remove actions stay
  put. Fallback when no cover_photo_url (translucent gradient + PL/SK/AG
  initials) is unchanged, just inside the window body.

- Inner skill/agent cards in the plugin detail's Internal structure
  section adopt the same 715:310 aspect (was fixed 78px tall). No window
  chrome on inner cards — just the matching proportions so covers read
  consistently across hero, grid tiles, and listing cards.

- Curated nested item helper text ("This skill is part of ... — add the
  bundle to your stack to use it") now stacks UNDER the "Open parent
  plugin" button instead of being a side-by-side flex sibling in the
  actions-row. Added align-self: flex-end so the 260px helper box
  anchors at the right edge of the 300px actions column, matching the
  button's right edge.

* Marketplace My tab: surface the same category + type filters as Flea

- Frontend: mp-cat-row and mp-type-row now show on tab=my (previously
  hidden — type was flea-only, category was flea/curated-only). Curated
  browse stays plugin-only and continues to hide the type pills.
  fetchOne() sends the `type` param for tab=my too, so the items
  endpoint's existing my-branch filter actually receives it.

- Backend categories endpoint, tab=my branch: when the type filter is
  set to skill/agent, skip counting curated subscriptions. Curated
  plugins are always type='plugin', so they wouldn't survive the items
  endpoint's type filter; including them in the category counts made
  the pill numbers overstate what users could actually see in the
  grid. type=None or type='plugin' keeps the previous behaviour.

- CHANGELOG entry under [Unreleased].

* Marketplace plugin detail: render rich content from marketplace-metadata.json

Adds five optional plugin-level fields to marketplace-metadata.json and
renders them on the curated plugin detail page + listing card:

* display_name — friendly h1 / listing-card name / mac-window titlebar
  label (overrides the technical plugin id)
* tagline — punchy 1-line value prop for the hero subtitle and the
  listing card description (replacing the verbose marketplace.json
  description on cards)
* description — multi-paragraph markdown body, server-side rendered
  through markdown-it-py and sanitized through nh3 with a
  description-scoped allowlist (no iframes / no raw HTML / no
  javascript: links). Powers the "What it does" panel.
* use_cases[] — {title, description, prompt} entries that render as a
  3-column "When to use it" card grid; each card shows the literal
  prompt as a code chip so users can copy-paste into Claude Code.
* sample_interaction — {user, assistant} dialog rendered in a Claude
  Code-style dark Catppuccin Mocha transcript panel: monospace user
  row with a green ">" prompt indicator + sans-serif assistant body
  with markdown formatting (peach bold, yellow italic, pink inline
  code, mantle-dark fenced code blocks).

All five fields are optional; UI sections only render when populated,
so plugins without enrichment look identical to before. Fields are
read on-demand from the working tree (cached by mtime per marketplace
slug) so curator edits land at the next request without waiting for
a sync cycle — same pattern as the existing inner-skill/agent
enrichment path. No DB schema bump.

Skill / agent rich-content rendering is deferred to a later phase
(needs a source-of-truth decision: extend plugin.yml? LLM-generate
from SKILL.md / agent.md?). The schema accepts the same fields at
skill/agent level today for forward compatibility but the UI ignores
them for now.

Also: stripped a stale `background-color: var(--bg)` from the global
`code` rule in style.css (was making inline code visually disappear
on the page background).

* Skill / agent detail: render rich content from marketplace-metadata.json

Brings the skill/agent detail pages to parity with the plugin detail
page. Same rich-content schema (display_name, tagline, description as
markdown, use_cases[], sample_interaction) plus two per-item additions:

* invocation — curator-provided literal command string. When set,
  overrides the computed "<manifest_name>:<inner_name>" chip and
  cleanly supports both "/" skill prefix and "@" agent prefix (the
  hardcoded "/" in the chip markup is hidden when the curator provides
  the invocation, so /grpn-eng:query <q> and @grpn-eng:cto-architect
  both render correctly).
* when_to_use — markdown disambiguation block ("Use this for X. For
  similar Y, see /other-skill") rendered into a new "When to use this"
  panel below the Example section.

Skill / agent category is now per-item overridable in
marketplace-metadata.json. When absent, the API keeps the parent
plugin's category as the badge so existing items don't lose their
category until curators opt in to per-item categorization.

The new "Example" Q&A panel uses the same Claude Code-style dark
Catppuccin Mocha transcript treatment as the plugin detail —
monospace user row with a green ">" prompt indicator + sans-serif
assistant body with markdown formatting.

All new fields are optional and read on-demand from the working tree.
Skills / agents whose marketplace-metadata.json doesn't carry rich
content render exactly the same way they did before (frontmatter
description + computed slash command + cover from existing v32
enrichment). No DB schema bump.

* Fix TypeError in skill / agent detail when curator sets per-item category

`curated_skill_detail` and `curated_agent_detail` were passing both
`**parent` (from `_curated_inner_parent_fields`, which returns the
parent plugin's category as a fallback) and `**enrichment` (from
`_curated_inner_enrichment`, which returns the per-item category
override when the curator set one) into `InnerDetailResponse(...)`.

Python function-call kwargs unpacking with overlapping keys raises
`TypeError: got multiple values for keyword argument 'category'`
— it doesn't merge like a literal dict does. The bug only surfaced
when the marketplace-metadata.json carried a `category` field at
skill / agent level (curator opting into per-item categorization);
items without that override hit the endpoint cleanly because only
parent provided the key.

Fix: build `merged = {**parent, **enrichment}` first (literal-dict
syntax DOES merge, with the right-hand-side winning) and unpack the
merged dict. Curator override still wins via the merge order, and
the same pattern is future-proof for any other field that lands in
both layers later.

Plus a regression test in test_marketplace_metadata.py asserting
that the inner-resolver carries `category` for downstream merging.

* Marketplace detail: tolerate partial curator JSON

Server constructed UseCase / SampleInteraction via raw dict indexing
(uc["title"], sample["assistant"]), so a curator commit missing any
required Pydantic field crashed the whole plugin / skill / agent detail
endpoint with a 500. Route both constructions through _safe_use_case /
_safe_sample_interaction helpers — partial input silently drops the
malformed card / section instead of breaking the page.

Regression test in test_marketplace_api.py covers the three shapes:
use_case missing a key, use_case with an empty string, and
sample_interaction with only user (no assistant). Sibling rich fields
still render.

* Address PR-251 review (must-fixes + S2/S3 polish) + release-cut 0.50.0

Five must-fixes from the review pass (3 from @cvrysanek's two-stage
review, 2 from my independent pass), plus the 0.50.0 release-cut as the
last commit on this PR per CLAUDE.md (CLAUDE.md "Release-cut belongs
to the PR" rule added in v0.49.1).

Must-fixes
----------

1. Cache eviction: bounded LRU instead of per-marketplace predicate.
   The previous predicate (`k[0] == marketplace_id and k[1] != mtime_ns`)
   only swept stale entries for the CURRENT marketplace; with N>100
   distinct marketplaces each holding one mtime key, the cap silently
   failed and memory grew linearly. Replaced with OrderedDict-backed
   bounded LRU at cap=256, drop oldest insert on overflow.
   Cache stress test pinned in test_marketplace_metadata.py.

2. Render CPU cap: per-field byte cap on description / when_to_use /
   sample_interaction.assistant via MARKETPLACE_METADATA_FIELD_MAX_BYTES
   (= 64 KiB). Without this, a 1 MiB curator markdown body × QPS =
   curator-controlled CPU burn through pure-Python markdown-it-py.
   Truncation respects UTF-8 boundaries and logs a warning so the
   curator sees the cap fire on the next sync. Test for cap +
   UTF-8-boundary preservation.

3. Inner-detail bypassed the metadata cache. _curated_inner_enrichment,
   _curated_inner_cover, and curated_detail all called
   read_marketplace_metadata directly, defeating the mtime cache the
   plugin listing already shared. Routed all three through
   _read_metadata_cached so skill/agent detail hits are O(1) re-parses
   per marketplace per mtime instead of O(QPS).

4. Truthy-vs-presence trap in plugin/inner enrichment merge. API-layer
   writers used `if resolved.get(k):` which silently dropped any
   future falsy-but-valid resolver field (bool featured=False, int
   priority=0, str category=''). Switched to presence check
   (`if k in resolved`) so the resolver is the authority on field
   presence; `{**parent, **enrichment}` merge respects whatever the
   resolver decided to ship.

5. Vendor-agnostic OSS cleanup. Removed operator-specific token
   references (/grpn-eng:, @grpn-eng:, .foundryai/) from
   src/marketplace_metadata.py docstring, app/web/templates/
   marketplace_item_detail.html JS comment, docs/curated-marketplace-
   format.md, and tests/test_marketplace_metadata.py fixtures. Replaced
   with generic /my-plugin:tool / @my-agent:role / .example/ placeholders.

CHANGELOG
---------
- New "### Fixed (PR #251 follow-ups)" section documenting all 4
  code-side must-fixes
- New "### Internal" section noting the vendor cleanup + new tests
- BREAKING bullet for the file rename now covers operator-side
  migration: running instances see plugin enrichment disappear from
  the UI until upstream curator renames + nightly sync overwrites the
  working tree; POST /api/marketplaces/{id}/sync forces refresh sooner
- Stripped /grpn-eng: leaks from the existing skill/agent rich-content
  bullet

Tests
-----
128 targeted tests pass (test_marketplace_metadata, test_marketplace_api,
test_marketplace, test_markdown_render, test_marketplace_synth_strip,
test_marketplace_filter). New tests added:
- 6 XSS regression tests on render_safe (javascript:/data:/vbscript:
  schemes via autolink, reference link, and mixed-case + positive
  http/https/mailto + noopener noreferrer rel)
- 3 byte-cap tests (truncation + UTF-8 boundary + under-cap pass-through)
- 1 cache eviction stress test (>256 marketplaces -> bounded at cap)
- 1 truthy-vs-presence resolver-contract test

Release-cut
-----------
- pyproject.toml 0.49.1 -> 0.50.0 (minor; BREAKING file rename per
  pre-1.0 CHANGELOG note: "breaking changes called out under Changed
  or Removed with the BREAKING marker")
- CHANGELOG [Unreleased] -> [0.50.0] - 2026-05-12, new empty
  [Unreleased] on top.

---------

Co-authored-by: Minas Arustamyan <arustamyan.minas@gmail.com>
Co-authored-by: ZdenekSrotyr <zdenek.srotyr@keboola.com>
2026-05-12 08:38:39 +00:00

943 lines
41 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% block title %}Marketplace — {{ config.INSTANCE_NAME }}{% endblock %}
{% block content %}
<style>
.mp-page {
--surface: #ffffff;
--primary-light: rgba(0, 115, 209, 0.12);
--border-light: #eceff1;
--text-primary: #202124;
--text-secondary: #5f6368;
--success-color: #10b77f;
--warn-color: #b45309;
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
--font-primary: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
/* ── Hero with integrated search ─────────────────────────────────── */
/* Layout split: cover image is anchored absolutely to the right edge
of the hero (z-index 1) so it doesn't claim layout space; the text
content sits on top (z-index 2) and uses the hero's full width.
`overflow: hidden` clips the cover to the hero's rounded corners. */
.mp-hero {
position: relative;
overflow: hidden;
background: linear-gradient(135deg, #0073D1 0%, #0056A3 100%);
border-radius: 12px;
padding: 28px 32px;
margin-bottom: 24px;
box-shadow: 0 4px 16px rgba(0, 115, 209, 0.2);
color: #fff;
}
.mp-hero-content {
position: relative;
z-index: 2;
}
.mp-hero-cover {
position: absolute;
right: 0;
top: 18px;
bottom: 0;
width: 46%;
object-fit: cover;
z-index: 1;
}
@media (max-width: 900px) {
.mp-hero-cover { display: none; }
}
.mp-hero .eyebrow {
font-size: 11px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.8px; color: rgba(255,255,255,0.75); margin-bottom: 8px;
}
.mp-hero h1 { margin: 0; font-size: 28px; font-weight: 700; letter-spacing: -0.4px; }
.mp-hero .sub { margin: 6px 0 0; font-size: 14px; color: rgba(255,255,255,0.85); }
.mp-hero .search-row {
display: flex; align-items: stretch;
margin-top: 18px;
background: #fff;
border-radius: 10px;
padding: 4px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
max-width: 760px;
}
.mp-hero .search-wrap { flex: 1; position: relative; min-width: 0; }
.mp-hero input[type="search"] {
width: 100%;
padding: 11px 14px 11px 40px;
border: none; border-radius: 8px;
font-size: 14px; font-family: var(--font-primary);
background: transparent; color: var(--text-primary); outline: none;
}
.mp-hero input[type="search"]::placeholder { color: var(--text-secondary); opacity: 0.75; }
.mp-hero .search-icon {
position: absolute; left: 14px; top: 50%; transform: translateY(-50%);
width: 16px; height: 16px; color: var(--text-secondary); pointer-events: none;
}
.mp-hero .search-btn {
appearance: none; border: none; background: var(--primary); color: #fff;
padding: 0 24px; border-radius: 8px;
font-size: 13px; font-weight: 600; font-family: var(--font-primary);
cursor: pointer; flex-shrink: 0;
transition: background 0.15s ease;
}
.mp-hero .search-btn:hover { background: var(--primary-dark); }
.mp-hero .search-btn:active { transform: scale(0.98); }
.mp-hero .scope {
display: flex; gap: 6px; align-items: center;
margin-top: 10px; font-size: 12px;
}
.mp-hero .scope-label {
font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px;
font-size: 11px; color: rgba(255,255,255,0.7); margin-right: 4px;
}
.mp-hero .scope label {
display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
user-select: none; padding: 4px 10px; border-radius: 6px; color: #fff;
}
.mp-hero .scope label:hover { background: rgba(255,255,255,0.12); }
.mp-hero .scope input[type="checkbox"] { accent-color: #fff; }
/* ── Tabs row + actions ──────────────────────────────────────────── */
.mp-tabs-row {
display: flex; align-items: center; justify-content: space-between;
gap: 16px; margin-bottom: 16px; flex-wrap: wrap;
}
.mp-tabs {
display: flex; gap: 4px; align-items: center;
background: var(--surface); border: 1px solid var(--border);
border-radius: 10px; padding: 4px;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
}
.mp-tabs button {
appearance: none; border: none; background: transparent;
color: var(--text-secondary);
padding: 8px 16px; border-radius: 7px;
font-size: 13px; font-weight: 500; cursor: pointer;
font-family: var(--font-primary);
display: inline-flex; align-items: center; gap: 8px;
transition: all 0.15s ease;
}
.mp-tabs button .tab-icon {
width: 16px; height: 16px; flex-shrink: 0;
}
/* Per-tab icon tint reuses the established palette: blue = curated/
vetted, purple = flea/community, amber = "in your stack" (mirrors
the In Stack badge on installed cards). The active-tab override
flips icons to white so they read against the blue active fill. */
.mp-tabs button[data-tab="curated"] .tab-icon { color: #0073D1; }
.mp-tabs button[data-tab="flea"] .tab-icon { color: #6D28D9; }
.mp-tabs button[data-tab="my"] .tab-icon { color: #F59F0A; }
.mp-tabs button.is-active .tab-icon { color: #fff; }
.mp-tabs button:hover { color: var(--text-primary); background: var(--bg); }
.mp-tabs button.is-active { background: var(--primary); color: #fff; }
.mp-tabs button .count {
display: inline-flex; align-items: center; justify-content: center;
min-width: 22px; height: 18px; padding: 0 6px; border-radius: 9px;
background: rgba(0,0,0,0.08); color: inherit;
font-size: 11px; font-weight: 600;
}
.mp-tabs button.is-active .count { background: rgba(255,255,255,0.2); }
.mp-actions { display: flex; gap: 8px; align-items: center; }
.mp-actions .btn {
appearance: none;
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 14px;
border: 1px solid var(--border); background: var(--surface);
color: var(--text-primary); border-radius: 8px;
font-size: 13px; font-weight: 500; cursor: pointer;
font-family: var(--font-primary); text-decoration: none;
transition: all 0.15s ease;
}
.mp-actions .btn:hover { border-color: var(--primary); color: var(--primary); }
.mp-actions .btn.primary {
background: var(--primary); color: #fff; border-color: var(--primary);
}
.mp-actions .btn.primary:hover {
background: var(--primary-dark); border-color: var(--primary-dark); color: #fff;
}
/* ── Info block (curated trust badge + flea open-shelf signal) ───── */
.mp-curator-block {
background: var(--surface);
border: 1px solid var(--border);
border-left: 3px solid var(--primary);
border-radius: 10px;
padding: 14px 18px;
margin-bottom: 16px;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
display: flex; gap: 16px; align-items: flex-start; flex-wrap: wrap;
}
/* Flea variant — purple accent matches the flea badge color used on
/marketplace cards and detail pages so the two info blocks read as
visually distinct (blue = curated/vetted, purple = flea/community).
The same purple is applied to the trailing link so it reads as part
of the flea block, not a stray curated-blue accent. */
.mp-curator-block.is-flea { border-left-color: #6D28D9; }
.mp-curator-block.is-flea .link { color: #6D28D9; }
/* My Stack variant — neutral slate. Not a trust/community signal like
the other two; it's the user's personal shelf, so the accent stays
intentionally non-branded so the user's own content is the focus. */
.mp-curator-block.is-mystack { border-left-color: #64748B; }
.mp-curator-block .text { flex: 1; min-width: 240px; }
.mp-curator-block .title {
font-size: 13px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px;
}
.mp-curator-block .body { font-size: 12px; color: var(--text-secondary); line-height: 1.5; }
.mp-curator-block .link {
font-size: 12px; color: var(--primary); font-weight: 500;
white-space: nowrap; align-self: center;
}
/* ── Filter row ──────────────────────────────────────────────────── */
.mp-filter-row {
display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
margin-bottom: 12px;
}
.mp-filter-row .pill {
appearance: none; border: 1px solid var(--border);
background: var(--surface); color: var(--text-primary);
padding: 7px 12px; border-radius: 999px;
font-size: 13px; font-weight: 500; cursor: pointer;
font-family: var(--font-primary);
display: inline-flex; align-items: center; gap: 6px;
transition: all 0.15s ease;
}
.mp-filter-row .pill svg { width: 14px; height: 14px; flex-shrink: 0; }
.mp-filter-row .pill:hover { border-color: var(--primary); color: var(--primary); }
.mp-filter-row .pill.is-active {
background: var(--primary-light); color: var(--primary); border-color: var(--primary);
}
.mp-filter-row .pill .count { color: var(--text-secondary); font-weight: 500; margin-left: 2px; }
.mp-filter-row .pill.is-active .count { color: var(--primary); }
.mp-type-row {
display: flex; gap: 6px; align-items: center; margin-bottom: 24px;
}
.mp-type-row .pill {
appearance: none; border: 1px solid var(--border);
background: var(--surface); color: var(--text-primary);
padding: 6px 12px; border-radius: 8px;
font-size: 12px; font-weight: 500; cursor: pointer;
font-family: var(--font-primary);
transition: all 0.15s ease;
}
.mp-type-row .pill:hover { border-color: var(--primary); color: var(--primary); }
.mp-type-row .pill.is-active {
background: var(--primary); color: #fff; border-color: var(--primary);
}
/* ── Card grid ───────────────────────────────────────────────────── */
.mp-grid {
display: grid; gap: 16px;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@media (max-width: 1100px) { .mp-grid { grid-template-columns: repeat(3, minmax(0,1fr)); } }
@media (max-width: 820px) { .mp-grid { grid-template-columns: repeat(2, minmax(0,1fr)); } }
@media (max-width: 540px) { .mp-grid { grid-template-columns: 1fr; } }
.mp-card {
position: relative;
display: flex; flex-direction: column;
background: var(--surface);
border: 1px solid var(--border); border-radius: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
overflow: hidden; cursor: pointer;
transition: all 0.15s ease;
/* Card is an <a> so middle-click / ctrl-click open in a new tab; reset
the link defaults so the styling matches the surrounding panel. */
text-decoration: none;
color: inherit;
}
.mp-card:hover {
border-color: var(--primary);
box-shadow: 0 6px 20px rgba(0, 115, 209, 0.12);
transform: translateY(-2px);
}
.mp-card .photo {
width: 100%; height: 120px;
display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, var(--primary-light) 0%, #fce7f3 100%);
color: var(--primary);
font-size: 26px; font-weight: var(--font-bold); letter-spacing: 0.5px;
}
.mp-card .photo img {
width: 100%; height: 100%; object-fit: cover;
}
.mp-card.is-installed { border-color: rgba(245, 159, 10, 0.55); }
/* Solid filled pill in amber — signals "selected / in your stack"
while staying readable on both the white card photo placeholder
and any uploaded cover image. Card border matches at low opacity
so the two installed-state cues read as a pair. */
.mp-card .installed-badge {
position: absolute; top: 10px; right: 10px;
display: inline-flex; align-items: center; gap: 4px;
padding: 4px 10px; border-radius: 999px;
background: #f59f0a; color: #fff;
font-size: 11px; font-weight: var(--font-semibold);
letter-spacing: 0.2px;
border: 1px solid rgba(255, 255, 255, 0.55);
}
.mp-card .body {
padding: 14px 16px 12px; flex: 1;
display: flex; flex-direction: column; gap: 6px;
}
.mp-card .badges { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.mp-card .type-badge {
display: inline-block; padding: 2px 8px; border-radius: 4px;
background: var(--primary-light); color: var(--primary);
font-size: 10px; font-weight: var(--font-semibold);
text-transform: uppercase; letter-spacing: 0.5px;
}
.mp-card .type-badge[data-type="plugin"] { background: rgba(0, 115, 209, 0.12); color: #0056A3; }
.mp-card .type-badge[data-type="skill"] { background: rgba(16, 183, 127, 0.14); color: #0e9b6a; }
.mp-card .type-badge[data-type="agent"] { background: rgba(124, 58, 237, 0.14); color: #6d28d9; }
.mp-card .cat-badge {
font-size: 10px; color: var(--text-secondary);
border: 1px solid var(--border); border-radius: 4px;
padding: 2px 7px;
text-transform: uppercase; letter-spacing: 0.4px; font-weight: 500;
}
.mp-card .name {
font-weight: var(--font-semibold); color: var(--text-primary);
font-size: 15px; line-height: 1.3;
}
.mp-card .by {
font-size: 11px; color: var(--text-secondary); margin-top: -2px;
}
.mp-card .by .todo { color: var(--warn-color); font-style: italic; }
.mp-card .desc {
font-size: 12px; color: var(--text-secondary); line-height: 1.5;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden;
}
.mp-card .footer {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 16px; border-top: 1px solid var(--border-light);
font-size: 11px; color: var(--text-secondary);
}
.mp-card .ver { font-variant-numeric: tabular-nums; }
/* ── Pager + empty state ─────────────────────────────────────────── */
.mp-pager { display: flex; gap: 6px; justify-content: center; margin: 32px 0 0; }
.mp-pager button {
padding: 7px 14px; border: 1px solid var(--border);
background: var(--surface); color: var(--text-primary);
border-radius: 8px; cursor: pointer; font-size: 13px;
font-family: var(--font-primary); transition: all 0.15s ease;
}
.mp-pager button:hover { border-color: var(--primary); color: var(--primary); }
.mp-pager button.is-active {
background: var(--primary); color: #fff; border-color: var(--primary);
}
.mp-pager .ellipsis {
display: inline-flex; align-items: center; padding: 0 4px;
color: var(--text-secondary); font-size: 13px;
}
.mp-empty {
text-align: center; padding: 56px 24px;
color: var(--text-secondary); font-size: 14px;
background: var(--surface);
border: 1px dashed var(--border); border-radius: 12px;
}
.mp-empty h3 {
margin: 0 0 8px; color: var(--text-primary); font-size: 16px; font-weight: 600;
}
.mp-empty .cta { margin-top: 16px; }
.mp-empty .cta a {
display: inline-block; margin: 0 6px;
color: var(--primary); font-weight: 500; text-decoration: none;
}
.mp-empty .cta a:hover { text-decoration: underline; }
[hidden] { display: none !important; }
</style>
<script id="category-icons-data" type="application/json">{{ category_icons_json | safe }}</script>
<div class="mp-page page-shell">
<!-- Hero with integrated search -->
<div class="mp-hero">
<div class="mp-hero-content">
<div class="eyebrow">Marketplace</div>
<h1>Plugin Marketplace</h1>
<p class="sub">Discover AI tools — from curated catalogs and the community.</p>
<div class="search-row">
<div class="search-wrap">
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/>
</svg>
<input id="mp-search" type="search"
placeholder="Search plugins, skills, agents — by name, description, author…">
</div>
<button class="search-btn" id="mp-search-btn" type="button">Search</button>
</div>
<div class="scope">
<span class="scope-label">Search in:</span>
<label><input type="checkbox" id="mp-scope-curated"> Curated</label>
<label><input type="checkbox" id="mp-scope-flea"> Flea Market</label>
</div>
</div>
<img class="mp-hero-cover" src="/static/marketplace-cover.png"
alt="" aria-hidden="true">
</div>
<!-- Tabs + actions -->
<div class="mp-tabs-row">
<div class="mp-tabs" role="tablist">
{# Tab icons (Heroicons outline 24×24): shield-check signals trust/vetting
for curated, building-storefront signals an open shelf for flea, and
rectangle-stack signals the user's personal collection for My Stack. #}
<button class="is-active" data-tab="curated" role="tab" aria-selected="true">
<svg class="tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z"/>
</svg>
Curated Marketplace <span class="count" data-count-curated>0</span>
</button>
<button data-tab="flea" role="tab" aria-selected="false">
<svg class="tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M13.5 21v-7.5a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75V21m-4.5 0H2.36m11.14 0H18m0 0h3.64m-1.39 0V9.349M3.75 21V9.349m0 0a3.001 3.001 0 0 0 3.75-.615A2.993 2.993 0 0 0 9.75 9.75c.896 0 1.7-.393 2.25-1.016a2.993 2.993 0 0 0 2.25 1.016c.896 0 1.7-.393 2.25-1.016a3.001 3.001 0 0 0 3.75.614m-16.5 0a3.004 3.004 0 0 1-.621-4.72L4.318 3.44A1.5 1.5 0 0 1 5.378 3h13.243a1.5 1.5 0 0 1 1.06.44l1.19 1.189a3 3 0 0 1-.621 4.72M6.75 18h3.75a.75.75 0 0 0 .75-.75V13.5a.75.75 0 0 0-.75-.75H6.75a.75.75 0 0 0-.75.75v3.75c0 .414.336.75.75.75Z"/>
</svg>
Flea Market <span class="count" data-count-flea>0</span>
</button>
<button data-tab="my" role="tab" aria-selected="false">
<svg class="tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M6.429 9.75 2.25 12l4.179 2.25m0-4.5 5.571 3 5.571-3m-11.142 0L2.25 7.5 12 2.25l9.75 5.25-4.179 2.25m0 0L21.75 12l-4.179 2.25m0 0 4.179 2.25L12 21.75 2.25 16.5l4.179-2.25m11.142 0-5.571 3-5.571-3"/>
</svg>
My Stack <span class="count" data-count-my>0</span>
</button>
</div>
<div class="mp-actions">
<a class="btn" data-actions-for="curated" href="/marketplace/guide/curated">Submit a plugin</a>
<!-- Flea has a self-service +Upload button below — no second
"how to" CTA needed. Anyone uploads via the form directly. -->
<a class="btn primary" data-actions-for="flea" href="/store/new" hidden>+ Upload</a>
</div>
</div>
<!-- Curator info block (Curated tab only) — trust badge, not onboarding.
Goal: signal "someone is accountable for what's in here", without
prescribing the process (teams may or may not have a curator; we
don't want the copy to imply a structure the org doesn't follow). -->
<div class="mp-curator-block" data-show-on="curated">
<div class="text">
<div class="title">Each plugin here has a named curator accountable for it.</div>
<div class="body">Each plugin in this marketplace has a named curator and meets a baseline review bar (security, telemetry hygiene, documentation).</div>
</div>
<a class="link" href="#">See all curators →</a>
</div>
<!-- Flea Market info block — open-shelf signal, mirror structure of
the curated block. The trailing "Tips for sharing" link points at
the publication guide; the +Upload button in the actions row above
handles the direct CTA, so this link is informational, not a
second action. Purple left-border + link colour distinguishes the
block from curated. -->
<div class="mp-curator-block is-flea" data-show-on="flea">
<div class="text">
<div class="title">Anyone in the company can upload here.</div>
<div class="body">The Flea Market is the community shelf — anyone can share a skill, agent, or plugin. Browse what colleagues have built, or upload something new.</div>
</div>
<a class="link" href="/marketplace/guide/flea">Tips for sharing →</a>
</div>
<!-- My Stack info block — personal-shelf orientation. Answers two
questions a non-technical user lands here with: "what is this?"
(their own collected items from Curated + Flea) and "how does
this connect to Claude Code?" (auto-sync at next session). No
trailing link — the user is on their own tab, navigating away
would be a distraction; the cards themselves are the action. -->
<div class="mp-curator-block is-mystack" data-show-on="my">
<div class="text">
<div class="title">Your AI stack — everything youve added.</div>
<div class="body">Plugins, skills, and agents youve picked from Curated or the Flea Market all live here. They sync to Claude Code on your laptop automatically the next time you start a session.</div>
</div>
</div>
<!-- Filter row: categories -->
<div class="mp-filter-row" id="mp-cat-row"></div>
<!-- Type filter (Flea + My — both surface skill/agent/plugin items) -->
<div class="mp-type-row" id="mp-type-row" hidden>
<button class="pill is-active" data-type="">All</button>
<button class="pill" data-type="skill">Skills</button>
<button class="pill" data-type="agent">Agents</button>
<button class="pill" data-type="plugin">Plugins</button>
</div>
<!-- Most Popular section: hidden until telemetry exists.
<div class="mp-section-header"><h2>Most Popular</h2><div class="meta">Last 30 days</div></div>
<div class="mp-grid">…8 cards…</div>
-->
<!-- Main grid -->
<div class="mp-grid" id="mp-grid"></div>
<div class="mp-empty" id="mp-empty" hidden>
<h3 id="mp-empty-title">Nothing here yet</h3>
<p id="mp-empty-body"></p>
<div class="cta" id="mp-empty-cta"></div>
</div>
<div class="mp-pager" id="mp-pager"></div>
</div>
<script>
'use strict';
const ICON_PATHS = JSON.parse(document.getElementById('category-icons-data').textContent);
const PAGE_SIZE = 24;
const state = {
tab: 'curated',
q: '',
category: null,
type: null,
page: 1,
scope: { curated: true, flea: true },
};
// ── URL state ────────────────────────────────────────────────────────────
function loadFromURL() {
const p = new URLSearchParams(window.location.search);
const tab = p.get('tab');
state.tab = (tab === 'flea' || tab === 'my') ? tab : 'curated';
state.q = p.get('q') || '';
state.category = p.get('category') || null;
state.type = p.get('type') || null;
state.page = Math.max(1, parseInt(p.get('page') || '1', 10));
}
function syncURL() {
const u = new URL(window.location.href);
u.searchParams.set('tab', state.tab);
['q','category','type'].forEach(k => {
if (state[k]) u.searchParams.set(k, state[k]);
else u.searchParams.delete(k);
});
if (state.page > 1) u.searchParams.set('page', String(state.page));
else u.searchParams.delete('page');
window.history.replaceState({}, '', u);
}
// ── DOM helpers ──────────────────────────────────────────────────────────
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, ch => (
{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]
));
}
function typeIcon(t) {
return t === 'skill' ? 'SK' : t === 'agent' ? 'AG' : t === 'plugin' ? 'PL' : '?';
}
function categorySVG(category) {
const path = ICON_PATHS[category || 'Other'] || ICON_PATHS['Other'] || '';
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">${path}</svg>`;
}
// ── Category filter ──────────────────────────────────────────────────────
function renderCategories(items) {
const row = document.getElementById('mp-cat-row');
row.innerHTML = '';
const allBtn = document.createElement('button');
allBtn.className = 'pill' + (!state.category ? ' is-active' : '');
allBtn.dataset.cat = '';
allBtn.textContent = 'All';
allBtn.addEventListener('click', () => {
state.category = null; state.page = 1; syncURL();
renderCategories(items); loadItems();
});
row.appendChild(allBtn);
for (const c of items) {
const btn = document.createElement('button');
btn.className = 'pill' + (state.category === c.name ? ' is-active' : '');
btn.dataset.cat = c.name;
btn.innerHTML = `${categorySVG(c.name)} ${esc(c.name)} <span class="count">${c.count}</span>`;
btn.addEventListener('click', () => {
state.category = c.name; state.page = 1; syncURL();
renderCategories(items); loadItems();
});
row.appendChild(btn);
}
}
async function loadCategories() {
const params = new URLSearchParams({ tab: state.tab });
if (state.tab === 'flea' && state.type) params.set('type', state.type);
try {
const res = await fetch('/api/marketplace/categories?' + params);
if (!res.ok) { renderCategories([]); return; }
const data = await res.json();
renderCategories(data.items || []);
} catch (e) {
renderCategories([]);
}
}
// ── Items grid ───────────────────────────────────────────────────────────
function renderGrid(items) {
const grid = document.getElementById('mp-grid');
const empty = document.getElementById('mp-empty');
grid.innerHTML = '';
if (!items || !items.length) {
grid.hidden = true;
empty.hidden = false;
fillEmptyState();
return;
}
grid.hidden = false; empty.hidden = true;
for (const it of items) {
const card = document.createElement('a');
card.className = 'mp-card' + (it.installed ? ' is-installed' : '');
card.href = it.detail_url;
// Cover photo with broken-image fallback. When the <img> 404s (e.g. an
// marketplace-metadata.json `cover_photo` references a file the curator forgot
// to commit, or an external mirror failed), browsers paint their default
// broken-image icon — which looks worse than the gradient placeholder we
// render when no cover_photo_url is set at all. The `onerror` swaps the
// parent's content for the same fallback initials (PL/SK/AG) so the card
// looks identical to the no-photo case.
const fallbackInitials = esc(typeIcon(it.type));
const photoMarkup = it.photo_url
? `<div class="photo"><img src="${esc(it.photo_url)}" alt=""
onerror="this.parentElement.classList.add('photo-failed');
this.parentElement.textContent=this.dataset.fallback;"
data-fallback="${fallbackInitials}"></div>`
: `<div class="photo">${fallbackInitials}</div>`;
// v39: when a plugin is system, the amber "Required" badge replaces
// the green "In stack" badge — system plugins are always installed
// (force-installed via mark_system materialization), so "in stack"
// is implied and stacking both pills wastes vertical space. The
// shield icon makes the org-policy semantic visually distinct from
// the user-action "✓ in stack" semantic.
let installedBadge = '';
if (it.is_system) {
installedBadge = `<div class="installed-badge" title="Required by your organization — managed by admin"
style="background:#fef3c7;color:#92400e;border-color:#fde68a;">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2 4 6v6c0 5 3.4 9.5 8 10 4.6-.5 8-5 8-10V6l-8-4z"/>
</svg>
Required
</div>`;
} else if (it.installed) {
installedBadge = `<div class="installed-badge" title="In your stack">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
In stack
</div>`;
}
// v32+ quarantine: subtle corner badge on the submitter's own
// non-approved cards. Approved cards + non-owner views omit it.
let quarantineBadge = '';
if (it.is_viewer_owner && it.visibility_status && it.visibility_status !== 'approved') {
const isPending = it.visibility_status === 'pending'
|| it.visibility_status === 'pending_inline'
|| it.visibility_status === 'pending_llm';
const label = isPending ? '⟳ Under review' : '⚠ Quarantined';
const palette = isPending
? 'background:#fef3c7;color:#92400e;border:1px solid #fde68a;'
: 'background:#fee2e2;color:#991b1b;border:1px solid #fecaca;';
quarantineBadge = `<div class="quarantine-badge"
title="Visible only to you. ${isPending ? 'Checks in progress.' : 'Failed automated/security checks — open the detail page to see why.'}"
style="position:absolute;top:8px;left:8px;padding:3px 9px;border-radius:999px;font-size:11px;font-weight:600;letter-spacing:0.2px;${palette}z-index:2;">${label}</div>`;
}
let ownerLine;
if (it.source === 'curated') {
const ownerVal = it.owner === 'owner_todo'
? `<span class="todo">owner_todo</span>`
: `<span>${esc(it.owner || '')}</span>`;
const mp = it.marketplace_name ? `via <strong>${esc(it.marketplace_name)}</strong> · ` : '';
ownerLine = `${mp}${ownerVal}`;
} else {
ownerLine = `by <strong>${esc(it.owner || '')}</strong>`;
}
const addedFmt = it.added ? new Date(it.added).toISOString().slice(0,10) : '';
const verFmt = it.version ? `v${esc(it.version)}` : '';
const catBadge = it.category ? `<span class="cat-badge">${esc(it.category)}</span>` : '';
// Rich-content fallback chain:
// - card name uses display_name (curator-friendly) when set; falls back
// to the raw `it.name` (plugin id from marketplace.json) otherwise.
// - card description uses tagline (1-line punchy value prop) when set;
// falls back to the marketplace.json `description` (often verbose
// technical copy, clamped to 2 lines by CSS line-clamp).
// Both fields come from marketplace-metadata.json — curated only;
// flea entities don't have a metadata layer yet.
const cardName = it.display_name || it.name;
const cardDesc = it.tagline || it.description || '';
card.innerHTML = `
${quarantineBadge}
${photoMarkup}
${installedBadge}
<div class="body">
<div class="badges">
<span class="type-badge" data-type="${esc(it.type)}">${esc(it.type)}</span>
${catBadge}
</div>
<div class="name">${esc(cardName)}</div>
<div class="by">${ownerLine}</div>
<div class="desc">${esc(cardDesc)}</div>
</div>
<div class="footer">
<span class="added">${addedFmt ? 'Added ' + addedFmt : ''}</span>
<span class="ver">${verFmt}</span>
</div>`;
grid.appendChild(card);
}
}
function fillEmptyState() {
const t = document.getElementById('mp-empty-title');
const b = document.getElementById('mp-empty-body');
const c = document.getElementById('mp-empty-cta');
if (state.q) {
t.textContent = `Nothing matches "${state.q}"`;
b.textContent = '';
c.innerHTML = '';
return;
}
if (state.tab === 'curated') {
t.textContent = 'No curated plugins available.';
b.textContent = 'Ask your admin to grant your group access.';
c.innerHTML = `<a href="/marketplace/guide/curated">Submit a plugin →</a>`;
} else if (state.tab === 'flea') {
t.textContent = 'No community entities yet.';
b.textContent = 'Be the first to share something.';
c.innerHTML = `<a href="/store/new">+ Upload</a>`;
} else {
t.textContent = 'My Stack';
b.textContent = "Plugins you've added to your stack will show up here. Browse the Curated Marketplace or Flea Market and click “Add to my stack”.";
c.innerHTML = `<a href="/marketplace?tab=curated">Browse Curated →</a><a href="/marketplace?tab=flea">Browse Flea Market →</a>`;
}
}
// ── Pager ────────────────────────────────────────────────────────────────
function renderPager(total) {
const pager = document.getElementById('mp-pager');
pager.innerHTML = '';
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
if (totalPages <= 1) return;
const mk = (label, page, active, isEll) => {
if (isEll) {
const s = document.createElement('span');
s.className = 'ellipsis'; s.textContent = '…'; return s;
}
const b = document.createElement('button');
b.textContent = label;
if (active) b.classList.add('is-active');
b.addEventListener('click', () => {
state.page = page; syncURL(); loadItems();
window.scrollTo({ top: 0, behavior: 'smooth' });
});
return b;
};
if (state.page > 1) pager.appendChild(mk('', state.page - 1, false));
// Compact pagination: 1 … (cur-1) cur (cur+1) … last
const pages = new Set([1, totalPages, state.page, state.page-1, state.page+1, 2, totalPages-1]);
const sortedPages = [...pages].filter(p => p >= 1 && p <= totalPages).sort((a,b) => a-b);
let prev = 0;
for (const p of sortedPages) {
if (p - prev > 1) pager.appendChild(mk('', 0, false, true));
pager.appendChild(mk(String(p), p, p === state.page));
prev = p;
}
if (state.page < totalPages) pager.appendChild(mk('', state.page + 1, false));
}
// ── Tab counts (separate cheap fetches) ──────────────────────────────────
async function loadTabCounts() {
const fetchCount = async (tab) => {
try {
const res = await fetch('/api/marketplace/items?tab=' + tab + '&page_size=1');
if (!res.ok) return 0;
const data = await res.json();
return data.total || 0;
} catch { return 0; }
};
const [c, f, m] = await Promise.all([
fetchCount('curated'), fetchCount('flea'), fetchCount('my'),
]);
document.querySelector('[data-count-curated]').textContent = c;
document.querySelector('[data-count-flea]').textContent = f;
document.querySelector('[data-count-my]').textContent = m;
}
// ── Items loader ─────────────────────────────────────────────────────────
async function loadItems() {
// Search-results mode: when user is searching with both scopes checked,
// we fan out two requests (one per scope) and merge client-side. With
// exactly one scope checked we just hit the matching tab.
if (state.q) {
const wantC = document.getElementById('mp-scope-curated').checked;
const wantF = document.getElementById('mp-scope-flea').checked;
if (!wantC && !wantF) {
document.getElementById('mp-scope-curated').checked = true;
state.scope.curated = true;
}
const fetches = [];
if (wantC) fetches.push(fetchOne('curated'));
if (wantF) fetches.push(fetchOne('flea'));
const results = await Promise.all(fetches);
const merged = [];
let total = 0;
for (const r of results) {
merged.push(...(r.items || []));
total += r.total || 0;
}
// Client-side paginate the merged set: backend already paginated each
// half so this is an approximate combined view, sufficient for search.
renderGrid(merged);
renderPager(total);
return;
}
const r = await fetchOne(state.tab);
renderGrid(r.items || []);
renderPager(r.total || 0);
}
async function fetchOne(tab) {
const params = new URLSearchParams({
tab,
page: String(state.page),
page_size: String(PAGE_SIZE),
});
if (state.q) params.set('q', state.q);
if (state.category) params.set('category', state.category);
// My stack also surfaces skill/agent/plugin entries (curated subscriptions +
// flea installs), so the type filter is meaningful there too. Curated
// browse only lists plugins, so we still skip the param for that tab.
if ((tab === 'flea' || tab === 'my') && state.type) params.set('type', state.type);
try {
const res = await fetch('/api/marketplace/items?' + params);
if (!res.ok) return { items: [], total: 0 };
return await res.json();
} catch { return { items: [], total: 0 }; }
}
// ── Tab switching + filter pills ─────────────────────────────────────────
function setTab(tab) {
state.tab = tab;
state.category = null;
state.type = null;
state.page = 1;
// Sync scope checkboxes with the active tab (user can manually flip back).
document.getElementById('mp-scope-curated').checked = (tab === 'curated');
document.getElementById('mp-scope-flea').checked = (tab === 'flea');
document.querySelectorAll('.mp-tabs button').forEach(b => {
const active = b.dataset.tab === tab;
b.classList.toggle('is-active', active);
b.setAttribute('aria-selected', active ? 'true' : 'false');
});
document.querySelectorAll('[data-actions-for]').forEach(a => {
a.hidden = a.dataset.actionsFor !== tab;
});
document.querySelectorAll('[data-show-on]').forEach(el => {
el.hidden = el.dataset.showOn !== tab;
});
// Category + type filters now show on both flea and my tabs (my surfaces
// a mix of plugin/skill/agent entries the user has installed). Curated
// browse only lists plugins, so type filter stays hidden there.
document.getElementById('mp-type-row').hidden = (tab === 'curated');
document.getElementById('mp-cat-row').hidden = false;
// Type filter pills always reset to 'All' on tab switch.
document.querySelectorAll('#mp-type-row .pill').forEach(p => {
p.classList.toggle('is-active', !p.dataset.type);
});
syncURL();
Promise.all([loadCategories(), loadItems()]);
}
// ── Boot ─────────────────────────────────────────────────────────────────
document.querySelectorAll('.mp-tabs button').forEach(b => {
b.addEventListener('click', () => setTab(b.dataset.tab));
});
document.querySelectorAll('#mp-type-row .pill').forEach(p => {
p.addEventListener('click', () => {
document.querySelectorAll('#mp-type-row .pill').forEach(x => x.classList.remove('is-active'));
p.classList.add('is-active');
state.type = p.dataset.type || null;
state.page = 1; syncURL();
Promise.all([loadCategories(), loadItems()]);
});
});
const searchInput = document.getElementById('mp-search');
const searchBtn = document.getElementById('mp-search-btn');
let searchTimer;
function runSearch() {
state.q = searchInput.value.trim();
state.page = 1;
syncURL();
loadItems();
}
searchInput.addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(runSearch, 250);
});
searchInput.addEventListener('keydown', e => {
if (e.key === 'Enter') { clearTimeout(searchTimer); runSearch(); }
});
searchBtn.addEventListener('click', () => {
clearTimeout(searchTimer); runSearch();
});
document.getElementById('mp-scope-curated').addEventListener('change', () => {
if (state.q) loadItems();
});
document.getElementById('mp-scope-flea').addEventListener('change', () => {
if (state.q) loadItems();
});
(async function init() {
loadFromURL();
// Restore search input from URL state
if (state.q) document.getElementById('mp-search').value = state.q;
// Sync scope checkboxes with the active tab on first load — only the
// active tab's source is searched by default. User can flip the other
// checkbox manually to widen the scope.
document.getElementById('mp-scope-curated').checked = (state.tab === 'curated');
document.getElementById('mp-scope-flea').checked = (state.tab === 'flea');
// For tab='my' fall back to Curated checked so a search query still has
// a non-empty scope to match against.
if (state.tab === 'my') {
document.getElementById('mp-scope-curated').checked = true;
}
// Apply tab to UI
document.querySelectorAll('.mp-tabs button').forEach(b => {
const active = b.dataset.tab === state.tab;
b.classList.toggle('is-active', active);
b.setAttribute('aria-selected', active ? 'true' : 'false');
});
document.querySelectorAll('[data-actions-for]').forEach(a => {
a.hidden = a.dataset.actionsFor !== state.tab;
});
document.querySelectorAll('[data-show-on]').forEach(el => {
el.hidden = el.dataset.showOn !== state.tab;
});
// Same visibility rule as setTab() — see comment there.
document.getElementById('mp-type-row').hidden = (state.tab === 'curated');
document.getElementById('mp-cat-row').hidden = false;
document.querySelectorAll('#mp-type-row .pill').forEach(p => {
p.classList.toggle('is-active', (p.dataset.type || null) === state.type);
});
await Promise.all([
loadTabCounts(),
loadCategories(),
loadItems(),
]);
})();
</script>
{% endblock %}