agnes-the-ai-analyst/app/web/templates/marketplace.html
minasarustamyan 302cf58ccd
feat(marketplace): telemetry v46 + flea inner parity + listing polish (#329)
* feat(telemetry): marketplace item rollup refactor (schema v46)

Replace the v42 attribution layer with prefix-split + live lookup against
marketplace_plugins / store_entities. The v42 design had a latent bug —
AttributionLookup keyed on bare skill names while Claude Code writes
`<plugin>:<local>` in JSONL, so lookups never matched and
usage_plugin_daily stayed empty in every deployment.

Schema (v46 migration):
- Drop usage_attribution_skills / _agents / _commands (mapping tables,
  derivable from marketplace_plugins + plugin tree).
- Drop usage_plugin_daily (always empty in production due to the bug above).
- Create usage_marketplace_item_daily — per-day fact (count, distinct_users,
  error_count), composite PK on (day, source, type, parent_plugin, name).
- Create usage_marketplace_item_window — sliding-window snapshot with
  true cross-window distinct user counts; period_label='last_7d' refreshes
  every tick, 'last_30d' refreshes hourly (tracked via session_processor_state).
- Mark usage_tool_daily as candidate for removal (no product-UI consumer).

Attribution flow:
- MarketplaceItemLookup replaces AttributionLookup. Preloads
  marketplace_plugins.name + store_entities.name into memory once per
  UsageProcessor tick, then per-event splits identifier on ':',
  matches prefix, writes resolved source / parent_plugin into
  usage_events. agnes-store-bundle prefix routes to flea entities.
  Slash commands with `plugin:` prefix count as type='skill' in rollup.

API:
- BREAKING: MarketplaceItem.unique_users_30d renamed to distinct_users_30d
  (now a true distinct count from the window snapshot, not sum-of-daily).
- InnerDetailResponse gains a telemetry field — invocations_30d +
  distinct_users_30d surfaced on curated inner skill / agent detail pages.
- Card chip hidden pending UX finalisation; data stays in the response.

Backfill: scripts/backfill_marketplace_rollup.py — one-shot rebuild over
historic usage_events after deploy, idempotent.

USAGE_PROCESSOR_VERSION bumped 4 → 5 so the reprocess loop re-attributes
existing events to the new source/ref_id semantics on the next tick.

Tests rewritten: test_session_processor_usage, test_usage_rollups,
test_marketplace_telemetry, test_api_admin_usage_reprocess,
test_db_schema_version, test_home_stats, test_schema_v42_migration.
New: test_backfill_marketplace_rollup.

* fix(marketplace): refresh Most Popular on search + category changes

`loadMostPopular()` early-exits when `state.q` or `state.category` is
set, but the search + category handlers only called `loadItems()` —
so once the section was visible, typing a query or filtering by
category didn't re-run the hide check and the cards stayed on screen
out of scope. Tab + sort handlers already chained the call.

Add the call to runSearch + category pill click handlers (All +
per-category) so the visibility contract holds for every state
mutation that can flip the early-exit condition.

* feat(marketplace): All-plugins section + 7-day Most Popular

Listing layout:
- Always-visible "All plugins" / "All items" / "Your stack" section
  header (label swaps per tab) wrapped in `#mp-all-section` so its
  margin-collapse mirrors the sibling `#mp-popular-section` and the
  spacing from the filter row stays consistent in both layouts.
- Sort dropdown moved from the filter row into the All-* header,
  pinned right via `margin-left: auto`. Anchored to its section so
  the relationship between sort + grid is obvious.
- `.mp-section-header` gets `min-height: 32px` + `align-items: center`
  so the bare-text Most Popular row matches the dropdown-bearing
  All-* row.
- `.mp-section-header` margin tightened 24px → 20px on top.

Most Popular:
- Capacity reduced 8 → 4 cards.
- Now reflects a 7-day window (was 30-day). Backend surfaces
  `invocations_7d` + `distinct_users_7d` on `MarketplaceItem`
  alongside the existing 30d fields; the loader pulls a wider page
  (server still sorts by 30d) and re-sorts + filters client-side
  on `invocations_7d > 0` so the strip stays "hot right now".
- Section label updated to "Last 7 days".
- Section now renders on both `curated` and `flea` tabs (was
  curated-only). Hidden on `my` and whenever search / category
  filter is active. Refresh hooks wired into search + category
  click handlers so visibility flips immediately on state change.

Backend (`_load_invocation_stats`):
- Single SELECT pulls both `last_30d` and `last_7d` rows from
  `usage_marketplace_item_window`; the result dict carries
  invocations + distinct_users for both windows.
- Trend (recent_7 vs prior_7) kept on the daily fact table so it
  stays independent of the window snapshot's freshness.

* feat(marketplace): Most adopted sort + hide Trending when no trend data

Add a fourth sort option to the All-items dropdown — "Most adopted
(30d)", keyed on `MarketplaceItem.distinct_users_30d` (true 30d
distinct user count from `usage_marketplace_item_window`). Protects
the listing from power-user skew that `most_used` is susceptible to:
one user × 100 invokes can't beat 10 different users × 1 invoke
under adoption sort.

Hide Trending option when the response has no trend data. User
reported `sort=trending` returning an empty grid because every
plugin's `trend_pct` was None (prior-week threshold of >= 3
invocations didn't clear anywhere). Empty grids on a user-selected
sort are worse UX than just not offering the sort — surface what
works, hide what doesn't.

Backend (`app/api/marketplace.py`):
- `_apply_sort` gains a `most_adopted` branch (DESC distinct_users_30d,
  ties by name ASC).
- `sort` Literal extended.
- `ItemListResponse.available_sorts` lists the sort keys the UI
  should expose for this response. recent/most_used/most_adopted
  always; trending only when at least one item in the tab's stats
  carries a non-null trend_pct.
- `_available_sorts(stats_dicts)` helper centralises the rule —
  curated and flea branches pass one stats dict, my-tab passes both
  (option is available when either source has trend data).

Frontend (`app/web/templates/marketplace.html`):
- New `<option value="most_adopted">Most adopted (30d)</option>`
  between Most used and Trending.
- URL state allowlist extended so `?sort=most_adopted` round-trips.
- `applyAvailableSorts(available)` runs after each list fetch:
  hides options not in the response's available_sorts; if the user
  is on a now-unavailable sort, resets to 'recent' and re-fetches.
  Search-mode fan-out unions availability across the curated + flea
  responses so a hit on either side keeps the option visible.

* feat(marketplace): funnel chip on cards + deterministic Most Popular sort

Card chip — funnel telemetry between description and footer:

  [stack-icon] N installed · [user-icon] N active · [bolt-icon] N calls · ↑/↓ N%

- stack_count (new MarketplaceItem field): for curated it's COUNT(*)
  on user_plugin_optouts (post-v28 row PRESENCE = subscribed; system
  plugins are fanned out to every user via fanout_system_for_user so
  the count includes them naturally). For flea it reuses the existing
  store_entities.install_count (bumped on install/uninstall).
- distinct_users_30d (existing) — active users in the 30d window.
- invocations_30d (existing) — call volume.
- trend_pct (existing) — week-over-week, both directions: green ↑ /
  red ↓, magnitude only (sign in the arrow). Hidden when null.

Backend additions in app/api/marketplace.py:
- MarketplaceItem.stack_count field.
- _load_curated_stack_counts() — one SELECT per render, GROUP BY
  (marketplace_id, plugin_name). Wired into the curated + my-tab
  branches; flea reads install_count off the entity row directly.

Frontend (app/web/templates/marketplace.html):
- Heroicons solid 24×24 inlined (one helper per icon, all
  fill="currentColor" so per-segment colour tokens apply): rectangle-
  stack (mirrors the My Stack tab icon), user, bolt, arrow-trending-
  up/down.
- Per-segment colour: installed=amber #F59F0A (My Stack accent),
  active=green #0e9b6a, calls=orange #f97316. Text stays neutral so
  the chip still reads as metadata, the leading glyph carries the
  visual cue. Trend pill keeps the full-segment green/red colour.
- Zero state: chip hidden when stack_count == 0 AND invocations_30d
  == 0 — brand-new cards aren't visually penalised by a "0·0·0" row.
- Tooltips on every segment via title="…" so hover explains the
  number's meaning to anyone uncertain about the icon.

Most Popular section — deterministic ordering:

Previously sorted by invocations_7d DESC with no tie-breakers, so
several cards with identical 7d call counts would swap places on
refresh (JS stable sort fell back on backend order, and the backend's
own tie-breaker for `most_used` was just name ASC — six `grpn`
plugins from six test marketplaces collapse to the same name and
became indeterminate via list_with_filters' created_at order).

New cascading hierarchy (chosen primary now matches what "most
popular" really means — wide adoption, not power-user volume):

  1. distinct_users_7d DESC  ← adoption / social proof
  2. invocations_7d   DESC  ← volume at equal adoption
  3. distinct_users_30d DESC ← broader adoption fallback
  4. invocations_30d  DESC  ← broader volume fallback
  5. name              ASC  ← deterministic textual order
  6. marketplace_slug  ASC  ← splits duplicate plugin names across
                              marketplaces

Six levels guarantee any two items end at a different sort key, so
the strip is stable across refreshes.

* fix(marketplace): unify Most Popular on 30d + right-align installed chip

Most Popular section was sorting on the 7d window while its cards
rendered 30d numbers — header label promised one thing, cards showed
another. Unified everything on 30d so a card means the same data
everywhere on the page.

- Dropped the "Last 7 days" meta from the Most Popular header.
- Sort cascade now starts on distinct_users_30d, then invocations_30d,
  with 7d adoption/volume as recency-aware fallbacks before the name +
  marketplace_slug deterministic tail. Six levels guarantee identical
  sort keys never produce indeterminate order across refreshes.
- Filter switched from invocations_7d > 0 to invocations_30d > 0 to
  match the new horizon.
- Most Popular now only renders on page 1 of the listing. Past initial
  discovery, a top-of-list popularity strip on page 2+ would shadow the
  results the user paged into. Pager click handler refreshes the
  section so navigating back to page 1 re-mounts it.

Chip layout — split engagement vs adoption visually:

  [user] N active · [bolt] N calls · [↑/↓] N%        [stack] N installed
  └────────── LEFT (time-bounded engagement) ────┘   └── RIGHT (all-time) ──┘

- Installed (stack_count) is all-time, decremented on uninstall. Alone
  it says little ("12 people installed it") without the engagement
  context next to it ("…but did anyone actually use it?"). Visually
  separating the two groups makes that distinction obvious — left
  group answers "is it used", right answers "does anyone have it".
- Implemented via flex with margin-left:auto on .seg-installed so
  installed drifts to the trailing edge.
- Installed tooltip now reads "Currently installed by N users" — the
  count is a real-time net (uninstall drops it), and saying "currently"
  makes that explicit. Helps when a card shows 0: signals "nobody has
  this in their stack right now", not "data missing".

* feat(plugin-detail): telemetry chip in hero, derived rows in sidebar

Surface the same telemetry funnel the listing card carries on the
curated plugin detail page, so clicking through from /marketplace
keeps a single mental model — figures match, semantics match. The
detail sidebar drops the two raw numbers that used to live there
(Invocations 30d / Users 30d — duplicated by the chip now) and
replaces them with two *derived* signals only the daily series can
provide: Active days + Last used.

Backend (app/api/marketplace.py):
- PluginDetailResponse.stack_count — curated reads via
  _load_curated_stack_counts(), flea reuses install_count. Frontend
  treats both sources uniformly.
- _build_telemetry() always returns a dict (never None). Frontend
  decides chip visibility from stack_count + invocations_30d the
  same way the listing card does. daily_series is always 30 entries
  (zero-padded) so "Active days" and "Last used" derivations on the
  sidebar are trivial array filters.

Frontend (app/web/templates/marketplace_plugin_detail.html):
- New .hero-telemetry slot at the bottom of the hero meta column,
  between the pills row and the action buttons. Renders the four
  funnel segments — active · calls · trend · installed — joined by
  ` · `. No left/right split: the hero has space, so a single
  coherent metadata strip reads cleaner than the card's split layout.
- Heroicons solid inlined (user / bolt / arrow-trending-up,-down /
  rectangle-stack) recoloured against the dark hero — icons in
  lighter tokens (mint #6ee7b7, peach #fdba74, cream #fde68a), trend
  pill keeps the saturated green/red because direction-coding earns
  its own colour.
- Tooltip on installed reads "Currently installed by N users" — the
  count is a real-time net (drops on uninstall), and "currently"
  makes that explicit when a card shows 0.
- fmtNum helper added so 1.2k / 14M renderings match the card's
  format exactly.
- Sidebar swap: Invocations + Users rows removed, replaced by
    Active days  →  "N of 30"
    Last used    →  fmtRelative of the latest non-zero day
  Both derived from telemetry.daily_series — engagement consistency
  + recency, neither of which the hero chip exposes on its own.

* feat(item-detail): telemetry chip in hero for curated skill/agent

Bring the funnel chip the plugin detail page got in 4cf38d40 to the
curated inner skill/agent detail page — clicking through from the
listing card now keeps the same metadata strip from grid to plugin
page to inner item page.

Backend (app/api/marketplace.py):
- _load_inner_item_stats() rewritten:
    * always returns a dict (never None) so the frontend can decide
      chip visibility client-side, same contract as _build_telemetry
    * adds trend_pct, computed the same way as plugin level
      (recent_7 vs prior_7 from usage_marketplace_item_daily, ≥3
      prior-week threshold)
    * adds daily_series (30 entries, zero-padded) so the sidebar can
      derive Active days + Last used
- InnerDetailResponse.parent_stack_count — new field. Skills/agents
  don't have a per-item subscription model, so the hero shows the
  *parent plugin's* stack count under a "Plugin:" prefix. The
  funnel: "12 installed plugin → 2 actually use this skill".
- curated_skill_detail + curated_agent_detail handlers load
  _load_curated_stack_counts() once and pass the parent's value.

Frontend (app/web/templates/marketplace_item_detail.html):
- New .item-detail .hero .hero-telemetry slot beneath the badges
  row. CSS mirrors plugin-detail's colour tokens (mint/peach/cream
  Heroicons solid + saturated trend pill) so the two surfaces read
  as one visual family.
- Installed segment uses a "Plugin:" label rendered with reduced
  opacity to signal the metric describes the parent, not the item
  itself. Tooltip: "Parent plugin (<plugin_name>) currently
  installed by N users".
- Sidebar Invocations + Users rows removed (chip carries them).
  Active days + Last used derived from telemetry.daily_series replace
  them; only rendered when activeDays > 0 so a brand-new skill
  doesn't show "0 of 30" / "Last used —".
- "Type" row dropped from the sidebar — duplicates the hero badge.
- fmtNum helper added (matches listing card + plugin detail).

Plugin detail (app/web/templates/marketplace_plugin_detail.html):
- Hero "Curator: …" line removed. The Details sidebar already
  carries that info; duplicating it under the h1 was visual noise.
- Sidebar "Owner" row renamed to "Curator" — for curated plugins
  it's a person who curates inclusion in this Agnes instance, not
  the upstream code owner. "Owner" was a hold-over label.

* feat(item-detail): unify hero with plugin detail — pills + breadcrumb + cleaner sidebar

- Inner skill/agent hero now uses the same `.pills` / `.pill.cat / .curated /
  .flea / .muted` class names + CSS as the plugin detail page; the only
  item-only addition is `.pill.type` (Skill / Agent uppercase, plugin detail
  has no kind axis).
- Hero `Updated` moved out of the meta-row into a muted pill (mirrors the
  plugin detail hero), removed from the Details sidebar to avoid duplication.
- Details sidebar slimmed: dropped Marketplace, Path, Updated rows; Parent
  plugin now shows the curator-friendly display name
  (`parent_display_name || manifest_name || slug`) instead of the slug.
- Breadcrumb extended to full path: Marketplace > <marketplace_name> >
  <plugin display name> > <self>, mirroring the plugin detail breadcrumb.
- Backend: new `InnerDetailResponse.parent_display_name` field, populated via
  `_curated_plugin_enrichment` from marketplace-metadata.json — same source
  plugin detail hero already uses.

* feat(marketplace): flea inner skill/agent detail + breadcrumb polish

- Flea inner skill/agent detail page parity with curated:
  * GET /api/marketplace/flea/{id}/skill/{name} + /agent/{name}
    returning InnerDetailResponse (mirror of curated_skill_detail).
  * /marketplace/flea/{id}/skill|agent/{name} web routes that render
    marketplace_item_detail.html with source='flea' + innerName context.
  * Frontend apiURL grows a third branch for flea-inner; breadcrumb
    grows to 4 segments (Marketplace > Flea Market > <plugin display
    name> > <self>) when innerName is set.
  * Telemetry attribution: MarketplaceItemLookup resolves
    <flea_plugin>:<inner> prefixes to (source='flea',
    parent_plugin=<plugin name>) so nested invocations land in the
    same rollups curated nested skills use. USAGE_PROCESSOR_VERSION
    bumped 5 -> 6 so the reprocess loop re-attributes historic events.
- Breadcrumb 2nd segment is now a generic clickable "Curated
  Marketplace" / "Flea Market" link to /marketplace?tab=... instead
  of the opaque per-instance marketplace_name. Applied on both plugin
  detail and inner item detail.
- Inner item hero telemetry chip works for both sources: installedCount
  branches on parent_stack_count (curated) vs install_count (flea),
  installed segment drops the "Plugin:" prefix for flea standalone /
  inner items.
- Updated row dropped from Details sidebar on item detail — the hero
  pill already carries the value, sidebar row was duplicate.

* feat(item-detail): block stack-install on flea inner items (mirror curated)

Inner skills/agents nested inside a flea plugin can no longer be added
to a user's stack on their own — adoption only happens at the plugin
level, same rule curated nested items have followed since launch.

- Hero action: when innerName is set (curated nested OR flea nested),
  render "Open parent plugin →" link + helper text instead of the
  install/remove buttons. Flea standalone entities (no innerName) keep
  the normal install UX.
- Meta-row: same branch now serves curated + flea inner — "part of
  <parent plugin display name> · by <author>" with the parent link
  pointing at the right detail page per source.

No API gate change needed: POST /api/store/entities/{id}/install only
accepts existing entity ids (plugin-level), inner items have no entity
id of their own so the endpoint cannot target them directly.

* feat(marketplace): telemetry chip on inner cards + fix flea hero chip visibility

Inner skill/agent cards on the plugin detail page now carry the same
four-segment funnel chip the marketplace listing cards show (N active
. N calls . trend . N installed), for both curated nested skills and
flea nested skills. Plus two fixes that were keeping the hero chip
hidden on flea plugin / flea inner detail pages.

- Backend `_load_inner_items_stats_by_parent(conn, source, parent_plugin)`
  bulk loader: one query per plugin against usage_marketplace_item_window
  + one against _daily, returning {(name, type): stats}. Avoids N+1
  per-card lookups.
- `InnerItemSummary` gains invocations_30d / distinct_users_30d /
  trend_pct / parent_stack_count fields. `curated_detail` and
  `flea_detail` (in the entity.type=='plugin' branch) enrich the
  skills / agents lists after the existing cover-photo enrichment loop.
- `marketplace_plugin_detail.html`: new `.plugin-detail .inner-card
  .inv-chip*` CSS lifted from marketplace.html with the listing-card
  rules, new buildInnerCardChip() helper, buildCardSection appends
  the chip to each card body. Same gate as the listing card (hidden
  on parent_stack==0 && calls==0).

- fix(flea): flea_detail forgot to populate PluginDetailResponse.stack_count
  from entity.install_count (listing card does this on line 851; detail
  endpoint didn't). Hero chip gate `stackCount===0 && calls===0` then
  always hid the chip even when the entity had installs. Now mirrors
  listing card semantics: stack_count == install_count for flea.
- fix(flea inner): renderInnerHeroTelemetry was reading `d.install_count`
  for any non-curated source. InnerDetailResponse has no install_count
  field — it has parent_stack_count (populated server-side from the
  parent flea plugin's install_count). Gate + label now read
  parent_stack_count for both curated nested AND flea nested scenarios;
  install_count remains the flea standalone path.

* fix(marketplace): Owner label on flea + parent-centric sidebar for flea inner

- Plugin detail Details sidebar — authorship row label now tracks the
  source: curated bundles get `Curator` (existing behaviour), flea
  bundles get `Owner`. The `owner_todo` reminder placeholder stays on
  the curated branch only; flea falls through silently.
- Inner item detail Details sidebar — flea-inner (skill/agent nested
  inside a flea plugin) now shares the curated nested layout: Parent
  plugin / Bundle size / Active days / Last used / Owner. Drops the
  flea-standalone shape's `Category`, `Version`, `Installs`, `Released`
  rows that didn't apply to a nested item. Active days + Last used were
  already wired (telemetryRows) — they just weren't on the flea-inner
  branch.

* fix(tests): bump SCHEMA_VERSION assertions 47 -> 48 post-rebase

The marketplace telemetry migration was renamed _v46_to_v47 -> _v47_to_v48
during the rebase onto main (collision with #326 FTS BM25 migration that
took the v47 slot). Two test files still asserted the pre-rebase value:

- tests/test_home_stats.py::test_schema_version_constant_is_46 (CI red)
- tests/test_schema_v46_migration.py::test_schema_version_is_46

Renames the helper fn name + bumps the assertion. The other two test
files (test_db_schema_version.py, test_schema_v42_migration.py) were
already updated in the rebase resolution.

* fix(telemetry): _build_telemetry returns None when invocations_30d == 0

The follow-up commit that introduced the always-return-dict shape broke
the test contract from the original v46 PR (commit b603e998):

  tests/test_marketplace_telemetry.py::TestDetailTelemetry::
    test_detail_endpoint_telemetry_absent_when_no_data
    AssertionError: assert {'daily_series': [...], ...} is None

Both `PluginDetailResponse.telemetry` and `InnerDetailResponse.telemetry`
are declared `Optional[Dict] = None`, the frontend renders are None-safe
(`d.telemetry || {}` guard + `if (!d.telemetry || ...)` on daily_series),
so dropping the dict on zero activity is the cleaner default.

* release: 0.54.21 — marketplace telemetry refactor (schema v48) + flea inner detail parity + listing UX polish

---------

Co-authored-by: Minas Arustamyan <arustamyan.minas@gmail.com>
Co-authored-by: ZdenekSrotyr <zdenek.srotyr@keboola.com>
2026-05-15 20:58:03 +02:00

1224 lines
56 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 {
font-size: 13px; font-weight: 500; cursor: pointer;
font-family: var(--font-primary); text-decoration: none;
transition: all 0.15s ease;
}
/* ── 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; }
/* Funnel chip — two groups on one row:
LEFT: active · calls · trend (engagement, time-bounded)
RIGHT: installed (passive adoption, all-time)
Installed is pushed to the trailing edge via margin-left:auto so
it visually reads as a separate category — it answers "how many
have this" while the left group answers "how is it used". */
.mp-card .inv-chip {
display: flex;
flex-wrap: wrap;
align-items: center;
column-gap: 8px;
row-gap: 4px;
font-size: 11px; color: var(--text-secondary);
margin-top: 6px;
line-height: 1.5;
}
.mp-card .inv-chip-left { /* inline container for · -joined segments */
display: inline;
}
.mp-card .inv-chip > span,
.mp-card .inv-chip-left > span {
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 3px;
}
.mp-card .inv-chip .seg-installed { margin-left: auto; }
.mp-card .inv-chip svg {
width: 12px; height: 12px;
flex-shrink: 0;
}
/* Per-segment icon colors — text stays neutral so the chip reads as
metadata, but the leading glyph is colour-coded so it scans fast.
Installed mirrors the amber accent of the My Stack tab (#F59F0A);
active is green for "real engagement"; calls is orange for energy. */
.mp-card .inv-chip .seg-installed > svg { color: #F59F0A; }
.mp-card .inv-chip .seg-active > svg { color: #0e9b6a; }
.mp-card .inv-chip .seg-calls > svg { color: #f97316; }
.mp-card .inv-chip .trend-up { color: #10b77f; font-weight: 600; }
.mp-card .inv-chip .trend-down { color: #ef4444; font-weight: 600; }
/* ── Sort select ─────────────────────────────────────────────────── */
.mp-sort-select {
appearance: none;
background: var(--surface) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%235f6368' stroke-width='2.5'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E") no-repeat right 10px center;
border: 1px solid var(--border); color: var(--text-primary);
padding: 7px 30px 7px 12px; border-radius: 8px;
font-size: 13px; font-weight: 500; cursor: pointer;
font-family: var(--font-primary);
transition: border-color 0.15s ease;
margin-left: auto;
}
.mp-sort-select:hover { border-color: var(--primary); }
.mp-sort-select:focus { outline: none; border-color: var(--primary); }
/* ── Section header (Most Popular + All plugins) ──────────────────── */
/* min-height pinned to the sort-select's box (font 13px + 7px*2
padding + 1px*2 border ≈ 32px) so the bare-text "Most Popular"
header sits at the same line-height as the "All plugins" header
that carries the dropdown. Without it the popular row was visibly
shorter and made the section spacing inconsistent. */
.mp-section-header {
display: flex; align-items: center; gap: 10px; margin: 20px 0 12px;
min-height: 32px;
}
.mp-section-header h2 {
margin: 0; font-size: 16px; font-weight: 700; color: var(--text-primary);
}
.mp-section-header .meta {
font-size: 12px; color: var(--text-secondary);
}
/* All-plugins header: title left, sort dropdown pinned right. The
margin-left:auto on the select handles the layout regardless of
whether Most Popular sits above. */
.mp-all-header > .mp-sort-select { margin-left: auto; }
/* ── 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 btn-secondary" data-actions-for="curated" href="/marketplace/guide/curated">Submit a skill or 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 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 + sort.
margin-bottom matches the original `.mp-filter-row { margin-bottom: 12px }`
so the Curated tab (where `.mp-type-row` is hidden) keeps healthy spacing
between the filter row and the card grid below. The Flea/My tab's
`.mp-type-row` adds its own 24px on top, totalling ~36px which is fine
visually. Pre-restoration this was 4px, leaving the Curated tab with
only 4px between filters and cards. -->
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:12px;">
<div class="mp-filter-row" id="mp-cat-row" style="margin-bottom:0; flex:1 1 auto;"></div>
</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: shown when any curated item has invocations_30d > 0 -->
<div id="mp-popular-section" hidden>
<div class="mp-section-header">
<h2>Most Popular</h2>
</div>
<div class="mp-grid" id="mp-popular-grid"></div>
</div>
<!-- Main section — wrapped in a div mirroring #mp-popular-section so the
header's margin-top collapses identically to its sibling above.
Without this wrapper the All-plugins header was a bare sibling of
the filter row and the margin computed differently from the Most
Popular header (which lives inside a wrapper). Title swaps per
tab via JS (curated → "All plugins"; flea + my mix all three
types so the label broadens). -->
<div id="mp-all-section">
<div id="mp-main-section-header" class="mp-section-header mp-all-header">
<h2 id="mp-main-section-title">All plugins</h2>
<select class="mp-sort-select" id="mp-sort-select" title="Sort by">
<option value="recent">Recent</option>
<option value="most_used">Most used (30d)</option>
<option value="most_adopted">Most adopted (30d)</option>
<option value="trending">Trending</option>
</select>
</div>
<div class="mp-grid" id="mp-grid"></div>
</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,
sort: 'recent',
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;
const rawSort = p.get('sort') || 'recent';
state.sort = ['recent','most_used','most_adopted','trending'].includes(rawSort) ? rawSort : 'recent';
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.sort && state.sort !== 'recent') u.searchParams.set('sort', state.sort);
else u.searchParams.delete('sort');
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(); loadMostPopular();
});
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(); loadMostPopular();
});
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 fmtNum(n) {
if (!n) return '0';
if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
return String(n);
}
function buildCard(it) {
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
// agnes-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 || '';
// Funnel chip — installed (passive adoption) → active users (real
// engagement) → calls (volume) → trend (momentum). Hidden when both
// installed and 30d invocations are zero so brand-new plugins don't
// get visually penalised by a "0 · 0 · 0" row. Each segment carries
// a tooltip for screen-readers + curious hover. Heroicons outline
// 24×24, inline so they recolor via currentColor and don't pull
// an asset request per card.
const stackCount = it.stack_count || 0;
const activeUsers = it.distinct_users_30d || 0;
const calls = it.invocations_30d || 0;
const trend = it.trend_pct;
// Heroicons solid 24×24 — filled glyphs so the chip reads as a row
// of small colourful badges rather than thin outlines. fill-rule
// evenodd because rectangle-stack ships as multi-path.
const _svg = (path) =>
`<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" clip-rule="evenodd" d="${path}"/></svg>`;
// rectangle-stack (solid) — same glyph as the My Stack tab (l. 451)
// but filled, so the "installed" segment visually echoes the tab.
const ICON_STACK = _svg("M5.566 4.657A4.505 4.505 0 0 1 6.75 4.5h10.5c.41 0 .806.055 1.183.157A3 3 0 0 0 15.75 3h-7.5a3 3 0 0 0-2.684 1.657ZM2.25 12a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H5.25a3 3 0 0 1-3-3v-6ZM5.25 7.5c-.41 0-.806.055-1.184.157A3 3 0 0 1 6.75 6h10.5a3 3 0 0 1 2.683 1.657A4.505 4.505 0 0 0 18.75 7.5H5.25Z");
// user (solid) — filled silhouette for active users.
const ICON_USER = _svg("M7.5 6a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM3.751 20.105a8.25 8.25 0 0 1 16.498 0 .75.75 0 0 1-.437.695A18.683 18.683 0 0 1 12 22.5c-2.786 0-5.433-.608-7.812-1.7a.75.75 0 0 1-.437-.695Z");
// bolt (solid) — lightning for call volume / energy.
const ICON_BOLT = _svg("M14.615 1.595a.75.75 0 0 1 .359.852L12.982 9.75h7.268a.75.75 0 0 1 .548 1.262l-10.5 11.25a.75.75 0 0 1-1.272-.71l1.992-7.302H3.75a.75.75 0 0 1-.548-1.262l10.5-11.25a.75.75 0 0 1 .913-.143Z");
// arrow-trending-up / -down (solid) — week-over-week momentum.
const ICON_TREND_UP = _svg("M15.22 6.268a.75.75 0 0 1 .968-.432l5.942 2.28a.75.75 0 0 1 .431.97l-2.28 5.94a.75.75 0 1 1-1.4-.537l1.63-4.251-1.086.483a11.2 11.2 0 0 0-5.45 5.174.75.75 0 0 1-1.199.19L9 12.31l-6.22 6.22a.75.75 0 1 1-1.06-1.06l6.75-6.75a.75.75 0 0 1 1.06 0l3.606 3.605a12.694 12.694 0 0 1 5.68-4.973l1.086-.484-4.251-1.631a.75.75 0 0 1-.432-.97Z");
const ICON_TREND_DOWN = _svg("M1.72 5.47a.75.75 0 0 1 1.06 0L9 11.69l3.756-3.756a.75.75 0 0 1 1.218.246l1.63 4.25 1.086-.483a11.2 11.2 0 0 1 5.45 5.174.75.75 0 0 1-1.199.19L17.34 13.79l-1.63 4.25a.75.75 0 0 1-1.218.246L11.07 14.97l-6.22 6.22a.75.75 0 1 1-1.06-1.06l6.75-6.75-7.81-7.811a.75.75 0 0 1 0-1.06Z");
let invChip = '';
if (stackCount > 0 || calls > 0) {
// Two semantic groups on one row:
// - LEFT (engagement, time-bounded): active · calls · trend
// - RIGHT (passive adoption, all-time): installed
// Installed alone says little ("12 people installed it") without
// engagement context ("…but did anyone use it?"), so it lives
// visually separated on the trailing edge — flex margin-auto.
const leftSegs = [
`<span class="seg-active" title="${activeUsers} users invoked it in the last 30 days">${ICON_USER} ${fmtNum(activeUsers)} active</span>`,
`<span class="seg-calls" title="${calls} invocations in the last 30 days">${ICON_BOLT} ${fmtNum(calls)} calls</span>`,
];
if (trend !== null && trend !== undefined) {
const up = trend >= 0;
const icon = up ? ICON_TREND_UP : ICON_TREND_DOWN;
const cls = up ? 'trend-up' : 'trend-down';
// Magnitude only — direction is in the arrow. Round to int
// (decimals add visual noise without precision value at glance scale).
leftSegs.push(`<span class="${cls}" title="Week-over-week change in invocations">${icon} ${Math.abs(Math.round(trend))}%</span>`);
}
const installedSeg = `<span class="seg-installed" title="Currently installed by ${stackCount} users">${ICON_STACK} ${fmtNum(stackCount)} installed</span>`;
invChip = `<div class="inv-chip">
<span class="inv-chip-left">${leftSegs.join(' · ')}</span>
${installedSeg}
</div>`;
}
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>
${invChip}
</div>
<div class="footer">
<span class="added">${addedFmt ? 'Added ' + addedFmt : ''}</span>
<span class="ver">${verFmt}</span>
</div>`;
return card;
}
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) grid.appendChild(buildCard(it));
}
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 skill or 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(); loadMostPopular();
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;
const availableUnion = new Set();
for (const r of results) {
merged.push(...(r.items || []));
total += r.total || 0;
for (const s of (r.available_sorts || [])) availableUnion.add(s);
}
applyAvailableSorts([...availableUnion]);
// 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);
applyAvailableSorts(r.available_sorts || []);
renderGrid(r.items || []);
renderPager(r.total || 0);
}
// Toggle visibility of sort options the current response says aren't
// available. Trending is the only conditional one — its DESC trend_pct
// sort returns an empty grid when the prior-week threshold hasn't
// cleared anywhere, so we hide the option instead. If the user is
// already on a now-unavailable sort, reset to 'recent' and re-fetch.
function applyAvailableSorts(available) {
const select = document.getElementById('mp-sort-select');
if (!select) return;
for (const opt of select.options) {
opt.hidden = !available.includes(opt.value);
}
if (!available.includes(state.sort)) {
state.sort = 'recent';
select.value = 'recent';
syncURL();
// Re-fetch on the recovered sort. Avoid recursing forever by only
// doing this when the response we just rendered was on the
// now-unavailable sort.
loadItems();
loadMostPopular();
}
}
async function fetchOne(tab, overrides = {}) {
const params = new URLSearchParams({
tab,
page: String(overrides.page ?? state.page),
page_size: String(overrides.page_size ?? 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);
const sortVal = overrides.sort ?? state.sort;
if (sortVal && sortVal !== 'recent') params.set('sort', sortVal);
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 }; }
}
// ── Most Popular loader ───────────────────────────────────────────────────
async function loadMostPopular() {
// Shown on curated + flea tabs (the discovery tabs) and only on the
// first page — on page 2+ the user is past initial discovery and a
// top-of-list popularity strip becomes redundant noise above the
// actual results they paged into. Also hidden when a search query
// or category filter is active so the result list isn't shadowed
// by a stale popularity strip. The All-* header stays put
// regardless — only this section toggles.
const section = document.getElementById('mp-popular-section');
if (!['curated', 'flea'].includes(state.tab) || state.q || state.category || (state.page || 1) > 1) {
section.hidden = true;
return;
}
try {
// Most Popular = top adopters over the **same 30d horizon** the
// rest of the listing uses. The card chip below already renders
// 30d numbers; sorting by a different window (7d) made the
// header label ("Last 7 days") promise something the cards
// weren't showing — cognitive mismatch. Unifying on 30d keeps a
// single mental model: every card means the same thing everywhere.
//
// Sort hierarchy — cascading tie-breakers prevent the "two cards
// with identical primary key swap on every refresh" UX bug:
//
// 1. distinct_users_30d DESC ← adoption (the "popular" signal:
// how many different people invoked it, not how many calls
// one power user fired)
// 2. invocations_30d DESC ← volume given equal adoption
// 3. distinct_users_7d DESC ← recency: among equally-adopted
// 30d plugins, the one with broader 7d adoption ranks higher
// 4. invocations_7d DESC ← recent volume
// 5. name ASC ← deterministic textual order
// 6. marketplace_slug ASC ← splits duplicate plugin names
// across multiple marketplaces (e.g. `grpn` in `groupon-
// marketplace` vs. test seeds)
const r = await fetchOne(state.tab, { sort: 'most_used', page: 1, page_size: 24 });
const cmp = (a, b) => {
const cmpN = (x, y) => (y || 0) - (x || 0);
let d = cmpN(a.distinct_users_30d, b.distinct_users_30d);
if (d) return d;
d = cmpN(a.invocations_30d, b.invocations_30d);
if (d) return d;
d = cmpN(a.distinct_users_7d, b.distinct_users_7d);
if (d) return d;
d = cmpN(a.invocations_7d, b.invocations_7d);
if (d) return d;
d = (a.name || '').localeCompare(b.name || '');
if (d) return d;
return (a.marketplace_slug || '').localeCompare(b.marketplace_slug || '');
};
const popular = (r.items || [])
.filter(it => (it.invocations_30d || 0) > 0)
.sort(cmp)
.slice(0, 4);
if (!popular.length) { section.hidden = true; return; }
const grid = document.getElementById('mp-popular-grid');
grid.innerHTML = '';
for (const it of popular) grid.appendChild(buildCard(it));
section.hidden = false;
} catch { section.hidden = true; }
}
// ── Sort select ──────────────────────────────────────────────────────────
document.getElementById('mp-sort-select').addEventListener('change', function() {
state.sort = this.value;
state.page = 1;
syncURL();
loadItems();
loadMostPopular();
});
// ── Tab switching + filter pills ─────────────────────────────────────────
function setTab(tab) {
state.tab = tab;
state.category = null;
state.type = null;
state.sort = 'recent';
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);
});
// Reset sort dropdown on tab switch.
document.getElementById('mp-sort-select').value = 'recent';
syncURL();
applyAllSectionTitle();
Promise.all([loadCategories(), loadItems(), loadMostPopular()]);
}
// All-plugins section title swaps per tab: curated lists only plugins,
// flea + my mix skills/agents/plugins so the label needs to broaden.
function applyAllSectionTitle() {
const el = document.getElementById('mp-main-section-title');
if (!el) return;
if (state.tab === 'curated') el.textContent = 'All plugins';
else if (state.tab === 'flea') el.textContent = 'All items';
else el.textContent = 'Your stack';
}
// ── 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();
loadMostPopular();
}
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);
});
// Restore sort select
const sortSel = document.getElementById('mp-sort-select');
sortSel.value = state.sort || 'recent';
applyAllSectionTitle();
await Promise.all([
loadTabCounts(),
loadCategories(),
loadItems(),
loadMostPopular(),
]);
})();
</script>
{% endblock %}