agnes-the-ai-analyst/app/web/templates/marketplace_plugin_detail.html
minasarustamyan c6c72b9c00
feat(flea): marketplace refactor — data model, attribution, UI unification (#342)
* feat(flea): phase-1 — title, tagline, synthetic_name columns + upload UX

Schema v49 adds three user-facing metadata columns to store_entities:

- title (NOT NULL) — humanized display name shown on marketplace
  surfaces in later phases. Acronym-aware humanizer in
  src/store_naming.py (27 entries: MCP, API, OAuth, S3, …) shared
  with the frontend via Jinja-injected dict so JS pre-fill and
  Python backfill produce identical output.
- tagline (NULL, ≤200 chars) — optional short description for card
  listings. Long-form `description` stays.
- synthetic_name (NOT NULL) — deterministic `<name>-by-<owner_username>`
  stored as a column for indexing and as the single source of truth
  for attribution lookups in later phases. Today's bundle bake still
  uses suffixed_name() at the same call sites.

Migration (_v48_to_v49_migrate, Python function — humanize has no
SQL equivalent) backfills existing rows: title from
humanize_name(strip_archive_suffix(name)), synthetic from the concat
formula; tagline stays NULL. Idempotent (ADD COLUMN IF NOT EXISTS +
SET NOT NULL no-op on re-run).

Upload form (store_upload.html step 2) reorders fields: Title
(pre-filled from server-side humanize, JS keeps it in sync until
the user edits manually) → Name + dark synthetic preview on one
row (matches marketplace_item_detail.html dark code styling, no
copy button — preview only) → Short description with character
counter → Description (unchanged). Edit form (store_edit.html)
mirrors the layout with pre-filled values from the entity row.

API:

- POST /api/store/entities/preview returns `title` (humanized
  fallback) for upload form pre-fill.
- POST + PUT /api/store/entities accept `title` and `tagline` form
  fields with 100/200-char validation; PUT recomputes
  synthetic_name when `name` changes (caller responsibility per
  repo contract).
- StoreEntityResponse exposes all three new fields.

Repository:

- create() takes title + tagline + synthetic_name as optional
  kwargs with derived defaults (humanize_name(name) / concat) so
  existing test fixtures don't need to thread them.
- update() supports partial updates on all three; tagline empty
  string clears via NULL sentinel.
- archive() recomputes synthetic_name on rename to the archived
  slug so the column stays consistent with name.

Tests:

- New test_schema_v48_to_v49_migration.py: fresh install,
  populated-row backfill (incl. archived row strip), idempotence,
  NOT NULL constraint verification.
- test_store_naming.py: 14 humanize parametrize cases + acronym
  dict invariants.
- test_store_api.py::TestStoreV49Metadata: preview humanize, POST
  with explicit + fallback title, 100/200-char rejects, PUT
  partial update + synthetic recompute on rename.
- Schema version assertion bumps (48 → 49) in test_db_schema_version,
  test_home_stats, test_schema_v42_migration, test_schema_v46_migration.

Phase 1 only — surface rendering on cards / detail pages and
Claude Code bundle propagation come in later phases.

* feat(flea): phase-2 — wire title/tagline/owner through marketplace cards + detail pages

Phase 1 (7f4cfcbb) populated the three new columns on store_entities;
phase 2 surfaces them across the web presentation layer so the kebab-
case slug + bare username no longer leak into user-facing copy.

API:

- `_flea_to_item` now takes `conn` (both callsites updated) and sets
  `display_name=entity.title`, `tagline=entity.tagline`, `owner=
  _resolve_owner_display(conn, owner_user_id, owner_username)` —
  matches the chain the curated path already uses (users.name →
  users.email → fallback). The card JS chain `it.display_name ||
  it.name` then renders the friendly form; `name` stays at the
  suffixed slug as the technical identifier JS uses for fallbacks.
- `flea_detail` adds `display_name` + `tagline` to PluginDetailResponse
  so the standalone skill/agent + plugin detail heroes pick them up
  through the existing `d.display_name` / `d.tagline` chains.
- `_flea_inner_parent_fields` swaps `parent_display_name` from
  `strip_archive_suffix(name)` to `entity.title or strip_archive_suffix(
  name)`. Drives parent-plugin label in four surfaces at once:
  breadcrumb 3rd segment, hero "part of <plugin>" meta-row,
  helper "This skill is part of <plugin>" panel, and the Details
  sidebar's "Parent plugin" row.

Templates — `marketplace_item_detail.html`:

- Pre-render: browser title, hero h1, and hero-window-label read
  `(entity.title if entity else None) or inner_name or item_name or
  plugin_name` so the SSR shell shows the friendly title before the
  JS fetch lands (no flash of kebab-case).
- Breadcrumb last segment for flea standalone drops the `d.manifest_name
  || heroTitle` fallback in favour of just `heroTitle` — manifest_name
  is the suffixed slug and users explicitly didn't want it in the path.
- Hero meta-row for flea standalone is now hidden. The prior "by
  <author> · N installed · <size>" line duplicated install count
  (hero telemetry chip below), owner + bundle size (Details sidebar).

Templates — `marketplace_plugin_detail.html`:

- Same SSR pre-render swap (title, h1, window-label, crumb-name).
- Hero tagline element starts hidden; JS shows it only when
  `d.tagline` is truthy. Pre-fix it fell back to `d.description`
  (long-form text), which read awkwardly under the h1 and pulled the
  hero too tall. Description still renders in the "What it does"
  panel below the hero.
- Initial "Loading…" placeholder removed so entities without a
  tagline don't flash that text mid-fetch.

Tests:

- New `TestFleaPhase2Presentation` class in test_marketplace_api.py
  (6 cases): card title + tagline + full-name owner, owner fallback
  chain when users.name is NULL, flea_detail exposes title + tagline,
  tagline null when omitted, inner skill parent_display_name uses
  entity.title (explicit + humanize-fallback variants).
- Updated `TestListItems.test_flea_lists_uploads` to assert both
  `display_name == "Alpha"` (humanized) and `name ==
  "alpha-by-alice"` (suffixed slug compat).
- Updated `TestWebPages.test_marketplace_flea_detail_page_renders`
  to look for the humanized title ("Page Skill") in the SSR shell
  instead of the kebab-case `page-skill`.

* feat(flea): phase-3 — read synthetic_name from DB, suffixed_name() only on write

Phase 1 added the column + backfill, repo write paths keep it in sync.
Phase 3 routes every READ callsite through `store_entities.synthetic_name`
directly instead of recomputing `<name>-by-<owner_username>` on the fly,
and switches the collision query off the inline string concat. The
`suffixed_name()` primitive now lives exclusively in write flows.

Read callsites updated (all read `entity["synthetic_name"]` directly,
no fallback — the column is NOT NULL and a missing value would be a
real bug worth surfacing as KeyError):

- app/api/marketplace.py:_flea_to_item — card MarketplaceItem.name.
- app/api/marketplace.py:flea_detail — PluginDetailResponse.manifest_name.
- app/api/store.py:_entity_to_response — StoreEntityResponse.invocation_name.
- app/api/store.py PUT bundle re-bake — `suffixed` passed to
  `_bake_plugin_tree`; entity is loaded pre-rename, so its
  synthetic_name is the OLD value `_bake_plugin_tree` expects.
- app/api/store.py PUT rename — `old_suffix` for `_rename_baked_tree`.
- app/api/my_stack.py — StoreInstallEntry.invocation_name.
- src/marketplace_filter.py — manifest_name in served plugin entry.

`suffixed_name` imports removed from marketplace.py, my_stack.py, and
marketplace_filter.py (no remaining callsites). store.py keeps the
import for its write paths:

- POST create (`suffixed = suffixed_name(final_name, username)` →
  passed to `_bake_plugin_tree` and `repo.create(synthetic_name=...)`).
- PUT rename collision check (`new_suffixed`).
- PUT rename `new_suffix` for `_rename_baked_tree` (proposed value).
- PUT rename `new_synthetic` for `repo.update(synthetic_name=...)`.
- Archive `old_suffix` + `new_suffix` for `_rename_baked_tree`
  (retro-compute pre-archive value after `repo.archive` already
  overwrote the DB row with the post-archive synthetic).

Collision SQL — `_suffixed_already_taken`:

  WHERE name || '-by-' || owner_username = ?   (before)
  WHERE synthetic_name = ?                     (after)

Same matches today (phase 1 backfill + NOT NULL invariant + write
paths in sync); indexable + single source of truth going forward.

Repository:

- UserStoreInstallsRepository.list_for_user explicit SELECT extended
  with `se.title`, `se.tagline`, `se.synthetic_name` so my_stack and
  marketplace_filter callers can read them off the joined row.

Tests:

- test_store_api.py::test_invocation_name_reads_from_synthetic_column —
  upload entity, manually override the column with a non-canonical
  value, verify GET response returns the override (proves read path
  consumes the column, not recomputes).
- test_marketplace_api.py::test_flea_card_and_detail_read_synthetic_name_from_db —
  same proof for `MarketplaceItem.name` (card) and
  `PluginDetailResponse.manifest_name` (detail).

* feat(flea): phase-4 — rename agnes-store-bundle → flea (synthetic plugin)

The synthetic plugin that wraps loose flea-market skills + agents into
one Claude Code plugin is renamed from `agnes-store-bundle` to `flea`.
Plugin-type flea uploads (their own standalone plugin entry) are
unaffected.

Constants:
- src/marketplace_filter.py:
  - BUNDLE_PLUGIN_NAME: "agnes-store-bundle" → "flea"  (Claude Code
    plugin manifest name + .claude-plugin/plugin.json name)
  - BUNDLE_PREFIXED_NAME: "store-bundle" → "flea"      (on-disk ZIP /
    git tree path, now plugins/flea/...)

Attribution layer (services/session_processors/usage_lib.py):
- FLEA_BUNDLE_PREFIX: "agnes-store-bundle" → "flea". The JSONL
  invocation identifier going forward is `flea:<skill-name>`.
- New `_LEGACY_FLEA_BUNDLE_PREFIXES = ("agnes-store-bundle",)`.
  `MarketplaceItemLookup.resolve()` + `_attribute_event()` accept BOTH
  the new and the legacy prefix so historic usage_events (~90-day
  retention) continue attributing to source='flea'. The tuple becomes
  a no-op once the rename has been live past the retention window —
  a follow-up commit can drop it then.
- USAGE_PROCESSOR_VERSION bumped 6 → 7 so the session-pipeline reprocess
  loop re-runs attribution with the new + legacy prefix branches.

User-facing copy:
- /api/store/bundle.zip Content-Disposition filename: agnes-store-bundle.zip → flea.zip
- `agnes admin store pull` default --out: agnes-store-bundle.zip → flea.zip
- Docstrings + JS comment + welcome template comment updated.

Tests:
- skill_flea.jsonl fixture identifier updated to flea:flea-skill.
- New skill_flea_legacy.jsonl with the legacy prefix for backward-compat
  coverage.
- New test `test_legacy_agnes_store_bundle_prefix_resolves` replays the
  legacy fixture and asserts source='flea' attribution still lands.
- All other test assertions / mocks substituted mechanically:
  test_session_processor_usage.py, test_usage_rollups.py,
  test_marketplace_filter_store.py, test_store_api.py,
  test_cli_refresh_marketplace.py.
- `_seed_flea_entity` (test_usage_rollups.py) + `_seed_attribution`
  (test_session_processor_usage.py) helpers now supply the NOT NULL
  `title` + `synthetic_name` columns from phase 1, since they INSERT
  directly bypassing the repo's create() fallback.

Client rollover note (CHANGELOG): `agnes refresh-marketplace` will
install the new `flea@agnes` plugin and the local marketplace clone's
`plugins/store-bundle/` source folder is removed via `git reset --hard`.
Whether Claude Code itself auto-prunes the orphan `agnes-store-bundle
@agnes` registry entry is undocumented — to verify empirically on the
dev VM. If the orphan entry lingers, a follow-up will add targeted
cleanup; until then users can manually run
`claude plugin uninstall agnes-store-bundle@agnes`.

Verified locally: 98 passed (session_processor_usage + usage_rollups +
marketplace_filter_store + cli_refresh_marketplace) + 228 passed/2
skipped (store_api + marketplace_api + admin_store_submissions +
store_entity_versions + store_repositories).

* fix(flea): phase-5 — attribution keyspace mismatch (closes #335)

Pre-fix every flea skill/agent invocation silently fell through to
`usage_events.source = 'builtin'`. Root cause: lookup tables in
`services/session_processors/usage_lib.py` keyed `_flea_entities` (and
the derived `_flea_plugins` set) by `store_entities.name` — the
un-suffixed display name. Claude Code writes invocations as
`flea:<synthetic_name>` (e.g. `flea:xlsx-by-c-marustamyan`), so
`dict.get(local)` always missed and the resolver fell through to
builtin. Result: marketplace cards, detail telemetry chips, admin
group-by-source all showed 0 flea invocations even when the raw
JSONL stream was correct.

Phase 1 added the `synthetic_name` column + backfill; phase 4 renamed
the bundle prefix to `flea`; phase 5 finally flips the lookup
keyspace to match what JSONL writes.

usage_lib.py:
- `MarketplaceItemLookup.__init__` preload: `SELECT synthetic_name,
  type FROM store_entities` (was `SELECT name, type`). `_flea_plugins`
  set derived from those keys, so it now carries synthetic_names
  too — matches what Claude Code writes when invoking a skill nested
  inside a flea plugin (`<synthetic>:<inner>`).
- `rebuild_rollups` preload: same SELECT change; also derives
  `flea_plugins` and threads it through `_aggregate_events` /
  `_rebuild_window`.
- `_attribute_event`: signature extended with `flea_plugins`; new
  branch `if prefix in flea_plugins: return ("flea", default_type,
  prefix, local)` for flea-plugin-nested skills/agents. This branch
  was added to `MarketplaceItemLookup.resolve()` in v6 (commit
  e076ebbe) but the rollup builder's helper was never updated to
  match, so nested skills inside flea plugins silently dropped out
  of the daily/window fact tables.
- `USAGE_PROCESSOR_VERSION`: 7 → 8. Forces the session-pipeline
  reprocess loop to re-attribute existing usage_events rows with
  the corrected lookup so rollup tables fill correctly on the next
  tick.

marketplace.py — 4 API stats lookup callsites switched from
`entity["name"]` to `entity["synthetic_name"]`:
- `_flea_to_item` (card stats lookup)
- `flea_detail` (`_build_telemetry` + `_load_inner_items_stats_by_parent`)
- `flea_skill_detail` (inner detail `parent_plugin` key)
- `flea_agent_detail` (inner detail `parent_plugin` key)

Tests:
- `skill_flea.jsonl` invocation: `flea:flea-skill` →
  `flea:flea-skill-by-alice` (mirrors what Claude Code writes after
  phase 1/4 — the suffixed synthetic_name).
- `test_flea_skill_attributed_with_empty_parent` assertion: rollup
  `name` column now carries the synthetic_name.

No legacy `agnes-store-bundle` prefix backward compat — clean cut per
user direction (dev phase, no production data worth preserving).

Verified locally: 53 passed targeted (session_processor_usage +
usage_rollups + marketplace_filter_store) + 215 passed/2 skipped
broader (store_api + marketplace_api + admin_store_submissions +
store_entity_versions).

* fix(flea): phase-6 — plugin-level rollup aggregation parity for flea

Flea plugin entity cards + detail pages showed 0 invocations even
though nested skills had correct rollup rows. Root cause: the
plugin-level aggregation pass in `_aggregate_events` was hardcoded
to `source='curated'` only:

    if source != "curated" or not parent:
        continue
    if group_by_day:
        pkey = (day, "curated", "plugin", "", parent)
    else:
        pkey = ("curated", "plugin", "", parent)

So flea plugin entities never got a synthetic
`(source='flea', type='plugin', parent_plugin='', name=<synth>)`
row aggregating nested invocations. `_load_invocation_stats('flea')`
filters `parent_plugin = ''` and returned no row for flea plugin
entity cards, so `stats.get(entity["synthetic_name"])` missed and
the API exposed 0/0.

Triggered by empirical observation on the dev VM —
`codex-second-opinion-by-c-marustamyan` plugin showed 0 calls in
the listing card while its three inner skills (codex-setup ×3,
codex-review ×1, codex-second-opinion ×1) had the expected child
rollup rows.

Fix:

- Extend the guard to `source in ("curated", "flea")`.
- Replace the hardcoded `"curated"` in the `pkey` tuple with the
  loop's `source` variable, so flea aggregation lands as `source=
  'flea'` and curated aggregation continues landing as
  `source='curated'`.

API path unchanged — `_load_invocation_stats('flea')` filters
`parent_plugin = ''` already picks up the new aggregated row
alongside standalone skill/agent rows. Rollup `name` field carries
the synthetic_name keyspace; no collision between standalone entity
synthetic and plugin entity synthetic (global suffix uniqueness
enforced by `_suffixed_already_taken`).

`USAGE_PROCESSOR_VERSION` bumped 8 → 9 to force a reprocess pass so
historic nested-invocation data fills the new plugin-level rows on
the next tick (instead of waiting for the next live invocation).

Tests:

- New `test_flea_plugin_row_aggregates_children` mirrors the existing
  `test_curated_plugin_row_aggregates_children`: seeds a flea plugin
  entity, three nested events (one user invoking two skills, a
  second user invoking one) → asserts the aggregated plugin row
  carries count=3, distinct_users=2 (union, not sum), plus the child
  rows survive alongside.

Verified locally: 43 passed (session_processor_usage + usage_rollups)
+ 82 passed/2 skipped broader (+ marketplace_filter_store +
marketplace_api).

* refactor(marketplace): phase-7 — unify Details sidebar across detail surfaces

Five marketplace detail surfaces (curated plugin, flea plugin, curated
inner skill/agent, flea inner skill/agent, flea standalone skill/agent)
had drifted on which Details rows they show and what order — the same
field landed in different positions, some fields duplicated hero info,
and the flea plugin Owner row leaked the kebab-case `owner_username`
slug instead of the user's real name. This commit aligns all five
surfaces on a single scan order driven by UX priority:

  identity → life-stage → telemetry → debug-tier

Concretely:

  1. Curator / Owner          (first scan signal — trust)
  2. Parent plugin            (inner skill/agent only)
  3. Released                 (top-level only — plugins + flea standalone)
  4. Last used                (recency)
  5. Active days              (engagement consistency)
  6. Version                  (flea standalone only — content hash)
  7. Bundle size              (debug-tier)

Dropped:

  - Slug field on plugin detail surfaces (`marketplace_id` for curated,
    `entity_id` for flea). Pure debug info, never user-relevant; URL
    already carries it.
  - Category + Installs on flea standalone skill/agent detail.
    Category is already shown as a hero badge; install count is in
    the hero telemetry chip — sidebar duplication added noise.

Owner display:

  - Flea plugin Owner row now reads `d.owner_display` (resolved through
    `users.name → users.email → owner_username` by `_resolve_owner_display`
    in `app/api/marketplace.py:1491`) instead of the raw `d.author_name`
    (which is `owner_username`, the kebab-case slug). API field already
    populated from phase 2; templates just consume it.
  - Curated Curator row continues to read `d.author_name` from
    marketplace-metadata.json; `owner_todo` placeholder behavior
    preserved.

Files:

  - app/web/templates/marketplace_plugin_detail.html — rewrote the
    Details render loop (lines 1364-1427 area). Slug row removed,
    rows reordered, Owner branch reads `d.owner_display`.
  - app/web/templates/marketplace_item_detail.html — both branches of
    the Details sidebar (inner skill/agent + flea standalone) re-laid
    around the same scan order. Telemetry helper unchanged, just
    repositioned. Category + Installs rows removed from the
    standalone branch.

No new tests — no existing test asserts the precise order of Details
rows or references the dropped fields in a sidebar context (grep
confirmed). API surface unchanged.

Verified locally: 84 passed / 2 skipped on `test_marketplace_api.py`
+ `test_store_api.py`.

* fix(flea): post-review hardening — N+1, v50 UNIQUE, docs, test cleanup

Addresses 5 critical findings from PR #342 code review:

1. N+1 query in `_flea_to_item` — owner-display resolution previously
   ran one `SELECT … FROM users WHERE id = ?` per item in the listing
   comprehension. Now batched via `_load_users_display` IN-query
   prefetch; 50 items drops 51 user queries to 2. Regression-guarded
   by `TestFleaOwnerDisplayBatched` (spies `_resolve_owner_display`
   and asserts it's not called inside the list path).

2. Misleading comment in `src/marketplace_filter.py` claimed the
   attribution layer accepts both `agnes-store-bundle` and `flea`
   prefixes — it doesn't (clean cut per CHANGELOG). Rewrote to match
   reality.

3. CHANGELOG `[Unreleased]` had two `### Changed` blocks. Merged into
   one (BREAKING bullet first).

4. New v49→v50 migration adds `UNIQUE INDEX
   idx_store_entities_synthetic_name`. v49 made `synthetic_name` the
   canonical attribution key but uniqueness was only app-enforced;
   v50 promotes the invariant to the DB layer. Migration pre-checks
   for existing duplicates and raises `RuntimeError` listing them
   rather than letting `CREATE UNIQUE INDEX` fail mid-way. v48→v49
   migration gained an `is_nullable='YES'` guard on its `SET NOT NULL`
   ALTERs so re-runs on a fully-migrated DB don't trip DuckDB's
   "cannot alter entry … entries depend on it" block (the new index
   counts as such an entry). Index is created by the migration only —
   keeping it out of `_SYSTEM_SCHEMA` preserves fresh-install ordering
   (CREATE TABLE → v49 ALTERs → v50 CREATE INDEX).

5. Deleted three redundant version-pinned schema asserts whose names
   lied about their bodies (`test_schema_version_is_42` asserting
   `== 49`, etc.). Canonical assert lives in
   `test_db_schema_version.py`, renamed to
   `test_schema_version_matches_constant`.

* fix(db): gate v34→v38 store_entities ALTER COLUMN steps on column state

CI on Linux failed `test_v17_to_v18_drops_*` after the v50 UNIQUE INDEX
landed. Root cause: those tests open a DB at the full target version,
seed fixtures, then reset `schema_version` to 17 and reopen — forcing
the ladder to re-run from 17 → current. With the v50 index now in place,
DuckDB blocks intermediate `ALTER COLUMN` steps on `store_entities`
("Cannot drop this column: an index depends on a column after it!" /
"Cannot alter entry because there are entries that depend on it"),
because `synthetic_name` (the indexed column) sits positionally after
the columns those steps touch.

Fix: convert the three SQL-list migrations that hit store_entities into
defensive Python functions:

- `_v34_to_v35_migrate` short-circuits when `synthetic_name` already
  exists (post-v49 shape — the visibility_status rebuild is moot and
  the DROP COLUMN would be blocked by the index).
- `_v35_to_v36_migrate` gates the `visibility_status SET NOT NULL` +
  `SET DEFAULT` on `is_nullable='YES'` so it's a true no-op when the
  column is already constrained.
- `_v37_to_v38_migrate` gates the `version_no SET NOT NULL` step the
  same way.

Forward-roll path (real installs that never reset schema_version) is
unchanged: the gates fire `YES` → ALTERs run. The fix only changes
behavior for the "DB is already at v50 shape but version row says 17"
scenario the tests construct.

---------

Co-authored-by: Minas Arustamyan <arustamyan.minas@gmail.com>
2026-05-19 02:32:41 +02:00

1556 lines
71 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 %}{{ (entity.title if entity else None) or plugin_name }} — {{ config.INSTANCE_NAME }}{% endblock %}
{% block content %}
<style>
.plugin-detail {
--primary-light: rgba(0, 115, 209, 0.12);
--border-light: #eceff1;
--text-primary: #202124;
--text-secondary: #5f6368;
--warn-color: #b45309;
--font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
}
/* ── Hero ─────────────────────────────────────────────────────────── */
.plugin-detail .hero {
position: relative;
background: linear-gradient(135deg, #0073D1 0%, #0056A3 100%);
border-radius: 14px;
padding: 22px 28px 28px;
margin-bottom: 24px;
box-shadow: 0 4px 16px rgba(0, 115, 209, 0.18);
color: #fff;
}
.plugin-detail .crumbs {
display: flex; gap: 6px; align-items: center;
font-size: 12px; color: rgba(255,255,255,0.78);
margin-bottom: 18px;
}
.plugin-detail .crumbs a { color: #fff; opacity: 0.92; text-decoration: none; }
.plugin-detail .crumbs a:hover { text-decoration: underline; }
.plugin-detail .crumbs .sep { opacity: 0.5; }
.plugin-detail .hero-head {
display: grid;
grid-template-columns: 380px minmax(0, 1fr) 300px;
gap: 22px;
align-items: start;
}
@media (max-width: 1100px) {
.plugin-detail .hero-head { grid-template-columns: 380px minmax(0, 1fr); }
}
@media (max-width: 720px) {
.plugin-detail .hero-head {
grid-template-columns: 1fr;
}
}
/* Hero window — macOS-style frame around the cover photo. The body has
aspect-ratio 715/310 so curator-uploaded covers never crop to a
square. Titlebar shows 3 traffic-light dots + the plugin's
manifest_name as a centered label. When cover_photo_url is missing
(or the image 404s), the body falls back to a translucent gradient
with the plugin's initials — same placeholder feel as before. */
.plugin-detail .hero-window {
width: 380px;
background: #1e293b;
border-radius: 10px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.10);
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.40),
0 2px 6px rgba(0, 0, 0, 0.18);
flex-shrink: 0;
align-self: start;
}
.plugin-detail .hero-window-titlebar {
background: linear-gradient(180deg, #2a3445 0%, #1e293b 100%);
height: 26px;
display: flex; align-items: center; gap: 6px;
padding: 0 10px;
border-bottom: 1px solid rgba(0, 0, 0, 0.25);
}
.plugin-detail .hwdot {
width: 11px; height: 11px; border-radius: 50%;
box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.18);
flex-shrink: 0;
}
.plugin-detail .hwdot.red { background: #ff5f56; }
.plugin-detail .hwdot.yellow { background: #ffbd2e; }
.plugin-detail .hwdot.green { background: #27c93f; }
.plugin-detail .hero-window-label {
margin: 0 auto;
/* mirror the dots' left footprint (~46px) on the right so the
label lands optically centered, not visually offset by the dots */
padding-right: 46px;
font-size: 10.5px; color: rgba(255, 255, 255, 0.55);
font-family: var(--font-mono);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
max-width: 100%;
}
.plugin-detail .hero-window-body {
aspect-ratio: 715 / 310;
overflow: hidden;
background: linear-gradient(135deg, rgba(255,255,255,0.18) 0%, rgba(255,255,255,0.04) 100%);
display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 44px; font-weight: 700; letter-spacing: 1px;
}
.plugin-detail .hero-window-body img {
width: 100%; height: 100%; object-fit: cover; display: block;
}
.plugin-detail .meta { min-width: 0; }
.plugin-detail h1 {
margin: 0 0 6px; font-size: 28px; font-weight: 700;
letter-spacing: -0.4px; color: #fff;
word-wrap: break-word;
}
.plugin-detail .tagline {
font-size: 14.5px; line-height: 1.6;
color: rgba(255,255,255,0.92); margin-bottom: 6px;
}
.plugin-detail .curator {
font-size: 12.5px; color: rgba(255,255,255,0.78);
margin-bottom: 14px;
}
.plugin-detail .curator strong { color: #fff; font-weight: 600; }
.plugin-detail .curator .todo { color: #FED7AA; font-style: italic; }
.plugin-detail .pills {
display: flex; gap: 6px; flex-wrap: wrap; align-items: center;
}
.plugin-detail .pill {
background: rgba(255,255,255,0.16); color: #fff;
padding: 3px 10px; border-radius: 999px;
font-size: 11px; font-weight: 500;
}
.plugin-detail .pill.cat { background: rgba(255,255,255,0.22); }
.plugin-detail .pill.ver { font-family: var(--font-mono); }
.plugin-detail .pill.curated { background: #FEF3C7; color: #B45309; font-weight: 600; }
.plugin-detail .pill.flea { background: #EDE9FE; color: #6D28D9; font-weight: 600; }
.plugin-detail .pill.muted { background: transparent; color: rgba(255,255,255,0.72); padding-left: 0; }
/* Hero telemetry chip — mirrors the listing card chip shape but
restyled for the gradient hero background (white text, larger
14px glyphs than the 12px on cards). All four segments are
joined by ` · ` and sit inline; the listing card's left/right
split via margin-auto isn't reused here — the hero has space,
so a single coherent metadata strip reads cleaner. */
.plugin-detail .hero-telemetry {
margin-top: 12px;
font-size: 12.5px;
color: rgba(255,255,255,0.92);
line-height: 1.7;
}
.plugin-detail .hero-telemetry > span {
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 4px;
}
.plugin-detail .hero-telemetry svg {
width: 14px; height: 14px;
flex-shrink: 0;
}
/* On the dark hero we tint icons in lighter tokens so they read
as a visual accent rather than punctuation. The trend pill
keeps a real colour because direction-coding genuinely helps. */
.plugin-detail .hero-telemetry .seg-active > svg { color: #6ee7b7; }
.plugin-detail .hero-telemetry .seg-calls > svg { color: #fdba74; }
.plugin-detail .hero-telemetry .seg-installed > svg { color: #fde68a; }
.plugin-detail .hero-telemetry .trend-up { color: #6ee7b7; font-weight: 600; }
.plugin-detail .hero-telemetry .trend-down { color: #fca5a5; font-weight: 600; }
.plugin-detail .actions {
/* Absolute on .hero — anchored at the top-right corner with equal
22px offsets from top and right. Hint card stacks below the
button row inside this absolute container, so it stays inside
the hero gradient and toggling it doesn't shift body content. */
position: absolute; top: 22px; right: 22px;
width: 300px;
display: flex; flex-direction: column; gap: 10px;
align-items: stretch;
z-index: 1;
}
@media (max-width: 1100px) {
.plugin-detail .actions {
position: static; width: auto; margin-top: 18px;
align-items: flex-end;
}
}
.plugin-detail .actions-row {
display: flex; flex-direction: row; align-items: center;
justify-content: flex-end; gap: 12px;
}
/* Without this rule the [hidden] HTML attribute is overridden by the
explicit `display: inline-flex` on .status-pill, leaving the "✓ In
your stack" label visible on plugins the user has NOT installed. */
.plugin-detail .actions [hidden] { display: none !important; }
.plugin-detail .btn-install {
appearance: none; cursor: pointer;
padding: 11px 22px; border-radius: 9px;
font-size: 13px; font-weight: 600; font-family: inherit;
/* Transparent border kept on the default so :hover can swap to a
visible white border without shifting the button's size. */
border: 1px solid transparent;
transition: all 0.15s ease;
background: #fff; color: var(--primary);
}
.plugin-detail .btn-install:hover {
/* Darken-glass — same formula as the secondary "Open parent plugin"
button on the skill/agent detail hero, so all hero-action hovers
feel consistent. The blue hero shows through the 20% black tint. */
background: rgba(0, 0, 0, 0.2);
border-color: rgba(255, 255, 255, 0.55);
color: #fff;
}
/* Status label — inline text indicator, NOT a button. No border, no
fill, no padding-as-chrome: this is a label that says "currently
in stack" sitting before the Remove button. The check glyph + light
green color carry the meaning; visual weight stays below the
adjacent button so the user's eye lands on the action. The system
variant uses an amber tone for the same lock semantic the SYSTEM
pill uses elsewhere. */
.plugin-detail .status-pill {
display: inline-flex; align-items: center;
font-size: 13px; font-weight: 500;
color: #fff;
cursor: default; user-select: none;
}
.plugin-detail .status-pill.is-system {
color: #fef3c7;
cursor: not-allowed;
}
/* Remove from stack — outlined red border by default so the
destructive intent is announced even before hover; on hover the
full red fill commits to the message. The red-on-blue contrast is
intentional — same palette logic as the X close button on the
hero's quarantine banner. */
.plugin-detail .btn-remove {
appearance: none; cursor: pointer;
/* Padding + font-size mirror .btn-install so the off-state CTA and
the on-state Remove button are the same physical height — no
layout shift when the user toggles the install state. */
padding: 11px 22px; border-radius: 9px;
font-size: 13px; font-weight: 600; font-family: inherit;
background: transparent; color: #fecaca;
border: 1px solid rgba(248, 113, 113, 0.7);
transition: all 0.15s ease;
}
.plugin-detail .btn-remove:hover {
background: rgba(220, 38, 38, 0.85); color: #fff;
border-color: rgba(220, 38, 38, 0.95);
}
.plugin-detail .btn-remove:focus-visible {
outline: 2px solid rgba(254, 202, 202, 0.85); outline-offset: 2px;
}
/* ── Post-add hint panel ─────────────────────────────────────────────
Inline next-steps recipe rendered after a successful "Add to my stack"
click. Lives below the description panel so the user sees it the
moment the page reflows from the Add action. The Catppuccin-Mocha
code chip mirrors the marketplace_item_detail invocation chip + the
/setup terminal blocks, so a familiar visual cue means "this is a
command you run in your terminal". */
/* Glass-on-gradient: lives inside the hero, sitting under the action
row in the third grid column. Translucent white over the blue
gradient reads as "elevated tile of the same hero" — no white-on-
white card insertion, no layout-shift below the hero (the hero
window's titlebar + 715:310 body run taller than the action+hint
stack, so toggling the hint visibility doesn't grow the hero in
practice). */
.plugin-detail .stack-hint {
padding: 10px 12px;
background: rgba(255, 255, 255, 0.14);
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 10px;
font-size: 12px;
color: rgba(255, 255, 255, 0.96);
line-height: 1.5;
backdrop-filter: blur(6px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
}
.plugin-detail .stack-hint .head {
display: flex; align-items: center; justify-content: space-between;
gap: 8px; margin-bottom: 4px;
}
.plugin-detail .stack-hint .title {
font-weight: var(--font-semibold);
color: #fff;
font-size: 12px;
}
.plugin-detail .stack-hint .dismiss {
appearance: none; background: transparent;
border: 1px solid rgba(255, 255, 255, 0.30);
color: rgba(255, 255, 255, 0.82); font-size: 10px; cursor: pointer;
padding: 2px 7px; border-radius: 5px;
font-family: inherit;
white-space: nowrap;
}
.plugin-detail .stack-hint .dismiss:hover {
color: #fff; background: rgba(255, 255, 255, 0.14);
border-color: rgba(255, 255, 255, 0.50);
}
.plugin-detail .stack-hint ol {
margin: 6px 0 0; padding-left: 20px;
color: var(--text-secondary);
}
.plugin-detail .stack-hint ol li { margin: 4px 0; }
.plugin-detail .stack-hint ol li strong { color: var(--text-primary); font-weight: var(--font-semibold); }
.plugin-detail .stack-hint .cmd-chip {
display: inline-flex; align-items: center; gap: 8px;
margin-top: 6px;
padding: 6px 10px;
background: #1e1e2e;
border-radius: 6px;
font-family: var(--font-mono); font-size: 12px;
color: #cdd6f4;
}
.plugin-detail .stack-hint .cmd-chip .prompt {
color: #a6e3a1; user-select: none; font-weight: var(--font-bold);
}
.plugin-detail .stack-hint .cmd-chip .btn-copy {
appearance: none; cursor: pointer;
padding: 2px 8px;
background: transparent;
border: 1px solid #45475a;
color: #cdd6f4;
border-radius: 4px;
font-size: 10px; font-weight: var(--font-medium);
font-family: var(--font-primary);
transition: all 0.15s ease;
}
.plugin-detail .stack-hint .cmd-chip .btn-copy:hover {
border-color: #89b4fa; color: #89b4fa;
background: rgba(137, 180, 250, 0.08);
}
.plugin-detail .stack-hint .cmd-chip .btn-copy.copied {
border-color: #a6e3a1; color: #a6e3a1;
}
.plugin-detail .stack-hint .learn-more {
display: inline-block; margin-top: 8px;
font-size: 12px; color: var(--primary); text-decoration: none;
}
.plugin-detail .stack-hint .learn-more:hover { text-decoration: underline; }
/* ── Top row ─────────────────────────────────────────────────────── */
.plugin-detail .top-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 20px;
margin-bottom: 24px;
align-items: stretch;
}
/* Spacing between the bottom panels (use-cases / sample / video / docs /
internal structure). The hero-row has its own margin-bottom; subsequent
stacked panels need equivalent breathing room or they collide visually
(~2px between bordered cards looks like a render bug). */
.plugin-detail #panel-use-cases,
.plugin-detail #panel-sample,
.plugin-detail #panel-video,
.plugin-detail #panel-docs,
.plugin-detail .structure {
margin-top: 24px;
}
@media (max-width: 900px) {
.plugin-detail .top-row { grid-template-columns: 1fr; }
}
.plugin-detail .panel {
background: var(--card-bg); border: 1px solid var(--border);
border-radius: 12px; box-shadow: 0 1px 2px rgba(0,0,0,0.04);
padding: 22px 26px;
}
.plugin-detail .panel h2 {
font-size: 15px; font-weight: 600;
margin: 0 0 14px;
text-transform: uppercase; letter-spacing: 0.6px;
color: var(--text-secondary);
}
.plugin-detail .lead { font-size: 14.5px; line-height: 1.65; color: var(--text-primary); white-space: pre-wrap; }
/* `.lead-rendered` switches the lead block from plain-text mode (which
uses `white-space: pre-wrap` so plain `description` line breaks survive)
to HTML mode (markdown-rendered body where paragraph breaks come from
`<p>` tags, not whitespace). pre-wrap would otherwise turn the gap
between two `<p>` blocks into stacked blank lines. */
.plugin-detail .lead-rendered { white-space: normal; }
.plugin-detail .lead-rendered > *:first-child { margin-top: 0; }
.plugin-detail .lead-rendered > *:last-child { margin-bottom: 0; }
.plugin-detail .lead-rendered p { margin: 0 0 12px; }
.plugin-detail .lead-rendered h2,
.plugin-detail .lead-rendered h3,
.plugin-detail .lead-rendered h4 {
/* Scoped overrides — the panel already provides an `<h2>` for the
section title; markdown headings nested inside the body shouldn't
inherit the uppercase / letter-spaced style of the section h2. */
font-size: 14.5px; font-weight: 600;
margin: 14px 0 6px;
text-transform: none; letter-spacing: 0; color: var(--text-primary);
}
.plugin-detail .lead-rendered ul,
.plugin-detail .lead-rendered ol { margin: 0 0 12px 22px; padding: 0; }
.plugin-detail .lead-rendered code {
background: var(--surface-alt, #f4f4f5); border-radius: 4px;
padding: 1px 5px; font-size: 0.92em;
font-family: var(--font-mono);
}
.plugin-detail .lead-rendered pre {
background: #1e1e2e; color: #cdd6f4;
border-radius: 8px; padding: 12px 14px;
font-family: var(--font-mono); font-size: 12.5px;
overflow-x: auto; margin: 8px 0 14px;
}
.plugin-detail .lead-rendered pre code {
background: transparent; padding: 0; color: inherit;
}
.plugin-detail .lead-rendered a { color: var(--primary); text-decoration: none; }
.plugin-detail .lead-rendered a:hover { text-decoration: underline; }
/* Use-cases grid — 3-column on wide, 2 on tablet, 1 on phone. Each card
has title, short description, and the literal slash-prompt the user
would paste into Claude Code. */
.plugin-detail .use-cases-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
@media (max-width: 1100px) {
.plugin-detail .use-cases-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 700px) {
.plugin-detail .use-cases-grid { grid-template-columns: 1fr; }
}
.plugin-detail .use-case-card {
border: 1px solid var(--border); border-radius: 10px;
padding: 14px 16px;
background: var(--surface-alt, #fafafa);
display: flex; flex-direction: column; gap: 8px;
}
.plugin-detail .use-case-card h3 {
margin: 0; font-size: 13.5px; font-weight: 600;
color: var(--text-primary); letter-spacing: 0;
text-transform: none;
}
.plugin-detail .use-case-card p {
margin: 0; font-size: 12.5px; line-height: 1.5;
color: var(--text-secondary);
}
.plugin-detail .use-case-prompt {
margin: 4px 0 0;
background: #1e1e2e; color: #a6e3a1;
border-radius: 6px;
padding: 8px 10px;
font-family: var(--font-mono); font-size: 12px;
line-height: 1.4;
overflow-x: auto;
white-space: pre-wrap; word-break: break-word;
}
/* Sample interaction — Claude Code transcript styling. Single dark
Catppuccin Mocha panel splits the user prompt from Claude's response
with a thin separator; same visual language as the .invocation chip
elsewhere so the curated detail page reads as "this is what you'll
get inside Claude Code", not "this is a generic chat app". */
.plugin-detail .sample-interaction {
background: #1e1e2e; /* mocha base */
border: 1px solid rgba(255,255,255,0.06);
border-radius: 10px;
overflow: hidden;
display: flex; flex-direction: column;
}
.plugin-detail .sample-user,
.plugin-detail .sample-assistant {
padding: 14px 18px;
}
.plugin-detail .sample-user {
border-bottom: 1px solid rgba(255,255,255,0.08);
background: rgba(255,255,255,0.015); /* a hair lighter — separates rows */
}
.plugin-detail .sample-label {
/* Hidden in the Claude-Code transcript styling. The `>` green prompt
* indicator on the user row + the bare prose body on the assistant
* row are the same visual cues a real Claude Code session uses to
* tell who's speaking — labels would add chrome the original
* doesn't have. The markup stays for accessibility (screen readers
* read the inline text) but is visually collapsed.
*/
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden; clip: rect(0,0,0,0);
white-space: nowrap; border: 0;
}
/* User prompt — monospace, leading `>` green prompt glyph. Reads like
a literal shell-input line the curator could copy-paste verbatim. */
.plugin-detail .sample-user > div:last-child {
font-family: var(--font-mono);
color: #cdd6f4; /* mocha text */
font-size: 13.5px; line-height: 1.55;
white-space: pre-wrap; word-break: break-word;
}
.plugin-detail .sample-user > div:last-child::before {
content: "> ";
color: #a6e3a1; /* mocha green */
font-weight: 700;
user-select: none;
}
/* Claude's response — sans-serif prose (Claude doesn't reply in mono),
markdown body lives inside. Inline code keeps the mono treatment so
code mentions still read as code. */
.plugin-detail .sample-assistant-body {
font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif);
color: #cdd6f4;
font-size: 13.5px; line-height: 1.65;
}
.plugin-detail .sample-assistant-body > *:first-child { margin-top: 0; }
.plugin-detail .sample-assistant-body > *:last-child { margin-bottom: 0; }
.plugin-detail .sample-assistant-body p { margin: 0 0 10px; }
.plugin-detail .sample-assistant-body strong { color: #fab387; } /* mocha peach */
.plugin-detail .sample-assistant-body em { color: #f9e2af; font-style: italic; } /* mocha yellow */
.plugin-detail .sample-assistant-body code {
background: rgba(255,255,255,0.06);
color: #f5c2e7; /* mocha pink */
border-radius: 4px;
padding: 1px 5px;
font-size: 0.92em;
font-family: var(--font-mono);
}
.plugin-detail .sample-assistant-body pre {
background: #181825; /* mocha mantle — darker nested */
border: 1px solid rgba(255,255,255,0.06);
border-radius: 8px;
padding: 12px 14px;
font-family: var(--font-mono); font-size: 12.5px; line-height: 1.5;
color: #cdd6f4;
overflow-x: auto;
margin: 8px 0;
}
.plugin-detail .sample-assistant-body pre code {
background: transparent; padding: 0;
color: inherit;
}
.plugin-detail .sample-assistant-body ul,
.plugin-detail .sample-assistant-body ol {
margin: 0 0 10px 22px; padding: 0;
}
.plugin-detail .sample-assistant-body li { margin: 2px 0; }
.plugin-detail .sample-assistant-body a {
color: #89b4fa; /* mocha blue */
text-decoration: none;
}
.plugin-detail .sample-assistant-body a:hover { text-decoration: underline; }
.plugin-detail .sample-assistant-body blockquote {
border-left: 3px solid #585b70; /* mocha surface2 */
margin: 8px 0;
padding: 4px 0 4px 12px;
color: #bac2de; /* mocha subtext1 */
}
.plugin-detail .sample-assistant-body h2,
.plugin-detail .sample-assistant-body h3,
.plugin-detail .sample-assistant-body h4 {
color: #cdd6f4;
font-size: 14px; font-weight: 600;
margin: 12px 0 6px;
text-transform: none; letter-spacing: 0;
}
.plugin-detail .details dl { margin: 0; }
.plugin-detail .details .row {
display: grid; grid-template-columns: max-content 1fr; gap: 12px;
padding: 10px 0; border-bottom: 1px solid var(--border-light);
font-size: 13px;
}
.plugin-detail .details .row:last-child { border-bottom: none; }
.plugin-detail .details dt { color: var(--text-secondary); margin: 0; font-weight: 500; }
.plugin-detail .details dd { margin: 0; color: var(--text-primary); font-weight: 500; text-align: right; }
.plugin-detail .details dd.mono { font-family: var(--font-mono); font-size: 12px; }
.plugin-detail .details dd .todo { color: var(--warn-color); font-style: italic; font-weight: 400; }
/* ── Internal structure ──────────────────────────────────────────── */
.plugin-detail .structure { margin-top: 4px; }
.plugin-detail .structure > h2 {
font-size: 16px; font-weight: 700;
margin: 0 0 16px; letter-spacing: -0.2px;
color: var(--text-primary); text-transform: none;
}
.plugin-detail .substruct {
background: var(--card-bg); border: 1px solid var(--border);
border-radius: 12px; box-shadow: 0 1px 2px rgba(0,0,0,0.04);
padding: 20px 24px; margin-bottom: 16px;
}
.plugin-detail .substruct .head {
display: flex; align-items: baseline; justify-content: space-between;
margin-bottom: 14px; padding-bottom: 12px;
border-bottom: 1px solid var(--border-light);
}
.plugin-detail .substruct .head h3 {
margin: 0; font-size: 14px; font-weight: 600; color: var(--text-primary);
}
.plugin-detail .substruct .head .count {
font-size: 12px; color: var(--text-secondary); font-family: var(--font-mono);
}
/* v32: demo video uses the shared `.video-embed` 16:9 wrapper from
style-custom.css — no scoped overrides needed here. */
.plugin-detail .doc-link-list {
list-style: none; padding: 0; margin: 0;
display: grid; gap: 8px;
}
.plugin-detail .doc-link-list li {
padding: 10px 12px; border-radius: 8px;
background: var(--surface-alt, #f9fafb);
border: 1px solid var(--border);
}
.plugin-detail .doc-link-list a {
color: var(--primary); text-decoration: none; font-weight: 500;
display: block;
}
.plugin-detail .doc-link-list a:hover { text-decoration: underline; }
/* Inner cards (skills + agents) */
.plugin-detail .inner-grid {
display: grid; gap: 14px;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@media (max-width: 1100px) { .plugin-detail .inner-grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 820px) { .plugin-detail .inner-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 540px) { .plugin-detail .inner-grid { grid-template-columns: 1fr; } }
.plugin-detail .inner-card {
display: flex; flex-direction: column;
background: var(--card-bg); border: 1px solid var(--border);
border-radius: 10px; overflow: hidden; cursor: pointer;
transition: all 0.15s ease; text-decoration: none; color: inherit;
}
.plugin-detail .inner-card:hover {
border-color: var(--primary);
box-shadow: 0 4px 14px rgba(0, 115, 209, 0.10);
transform: translateY(-1px);
}
.plugin-detail .inner-card .photo {
width: 100%; aspect-ratio: 715 / 310;
display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, var(--primary-light) 0%, #fce7f3 100%);
color: var(--primary);
font-size: 18px; font-weight: var(--font-bold);
letter-spacing: 0.5px;
border: none; border-radius: 0;
overflow: hidden;
}
.plugin-detail .inner-card .photo img {
width: 100%; height: 100%; object-fit: cover; display: block;
}
.plugin-detail .inner-card[data-type="skill"] .photo {
background: linear-gradient(135deg, rgba(16,183,127,0.18) 0%, #ecfdf5 100%);
color: #0e9b6a;
}
.plugin-detail .inner-card[data-type="agent"] .photo {
background: linear-gradient(135deg, rgba(124,58,237,0.18) 0%, #f5f3ff 100%);
color: #6d28d9;
}
.plugin-detail .inner-card .body {
padding: 12px 14px; flex: 1;
display: flex; flex-direction: column; gap: 5px;
}
.plugin-detail .inner-card .type-badge {
align-self: flex-start;
display: inline-block; padding: 2px 7px; border-radius: 4px;
font-size: 10px; font-weight: var(--font-semibold);
text-transform: uppercase; letter-spacing: 0.5px;
background: rgba(16, 183, 127, 0.14); color: #0e9b6a;
}
.plugin-detail .inner-card[data-type="agent"] .type-badge {
background: rgba(124,58,237,0.14); color: #6d28d9;
}
.plugin-detail .inner-card .name {
font-weight: var(--font-semibold); color: var(--text-primary);
font-size: 13.5px; line-height: 1.3;
font-family: var(--font-mono);
}
.plugin-detail .inner-card .desc {
font-size: 12px; color: var(--text-secondary); line-height: 1.5;
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
overflow: hidden;
}
/* Funnel chip on each inner card — lifted from marketplace.html
`.mp-card .inv-chip`, re-scoped to the plugin detail surface so
listing-card rules can evolve independently. Same colour mapping
so users carry mental model from listing → detail. */
.plugin-detail .inner-card .inv-chip {
display: flex; align-items: center; gap: 8px;
margin-top: 8px;
font-size: 11.5px; color: var(--text-secondary);
line-height: 1.4;
}
.plugin-detail .inner-card .inv-chip-left {
display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap;
}
.plugin-detail .inner-card .inv-chip > span,
.plugin-detail .inner-card .inv-chip-left > span {
display: inline-flex; align-items: center; gap: 3px;
white-space: nowrap;
}
.plugin-detail .inner-card .inv-chip .seg-installed { margin-left: auto; }
.plugin-detail .inner-card .inv-chip svg {
width: 13px; height: 13px; flex-shrink: 0;
}
.plugin-detail .inner-card .inv-chip .seg-installed > svg { color: #F59F0A; }
.plugin-detail .inner-card .inv-chip .seg-active > svg { color: #0e9b6a; }
.plugin-detail .inner-card .inv-chip .seg-calls > svg { color: #f97316; }
.plugin-detail .inner-card .inv-chip .trend-up { color: #10b77f; font-weight: 600; }
.plugin-detail .inner-card .inv-chip .trend-down { color: #ef4444; font-weight: 600; }
/* Tables (commands, hooks, mcps) */
.plugin-detail .substruct table { width: 100%; border-collapse: collapse; font-size: 13px; }
.plugin-detail .substruct th {
text-align: left;
font-size: 11px; font-weight: 600; color: var(--text-secondary);
text-transform: uppercase; letter-spacing: 0.5px;
padding: 8px 10px; border-bottom: 1px solid var(--border);
}
.plugin-detail .substruct td {
padding: 10px; border-bottom: 1px solid var(--border-light);
vertical-align: top; color: var(--text-primary);
}
.plugin-detail .substruct tr:last-child td { border-bottom: none; }
.plugin-detail .substruct .cell-name {
font-family: var(--font-mono); font-size: 12.5px; font-weight: 600;
color: var(--primary); white-space: nowrap;
}
.plugin-detail .substruct .cell-event,
.plugin-detail .substruct .cell-type {
font-family: var(--font-mono); font-size: 12px;
color: var(--text-secondary); white-space: nowrap;
}
.plugin-detail .substruct .cell-desc {
font-size: 12.5px; color: var(--text-secondary); line-height: 1.55;
}
.plugin-detail .empty-msg {
color: var(--text-secondary); font-size: 13px; font-style: italic;
}
</style>
<div class="plugin-detail page-shell" id="root"
data-source="{{ source }}"
data-marketplace-id="{{ marketplace_id or '' }}"
data-plugin-name="{{ plugin_name or '' }}"
data-entity-id="{{ entity_id or '' }}"
data-visibility="{{ entity.visibility_status if entity else 'approved' }}">
{# Quarantine banner — owner / admin only when non-approved. Self-guarded. #}
{% include "_quarantine_banner.html" %}
{# Owner-actions strip (Edit + Delete locked-when-not-approved). Mirrors
the policy that previously lived in store_detail.html. Edit is a
placeholder for now ("coming soon"); Delete is gated server-side
so the visible state matches what the API will accept. #}
{% if entity and (is_owner or is_admin) %}
<style>
.plugin-detail .owner-actions {
display: flex; gap: 10px; margin: 0 0 16px 0; justify-content: flex-end;
}
.plugin-detail .owner-actions a,
.plugin-detail .owner-actions button {
padding: 6px 14px; border-radius: 8px;
font-size: 13px; font-weight: 500; font-family: var(--font-primary);
text-decoration: none; border: 1px solid var(--border, #e5e7eb);
background: var(--surface, #fff); color: var(--text, #111827);
cursor: pointer;
}
.plugin-detail .owner-actions a:hover {
border-color: var(--primary, #0073D1); color: var(--primary, #0073D1);
}
.plugin-detail .owner-actions .delete {
color: #b91c1c; border-color: rgba(185,28,28,0.3);
}
.plugin-detail .owner-actions .delete:hover {
background: rgba(185,28,28,0.08); border-color: #b91c1c;
}
.plugin-detail .owner-actions button:disabled,
.plugin-detail .owner-actions a[aria-disabled="true"] {
color: #9ca3af !important; border-color: #e5e7eb !important;
background: #f3f4f6 !important; cursor: not-allowed;
}
</style>
<div class="owner-actions">
{# v37 edit feature: Edit lands a real page. Disabled while a
prior version is under review (server-side 409 also enforces).
edit_in_flight is set by the router whenever the latest
submission is pending_inline / pending_llm — even if the
entity stayed at visibility='approved' (deferred-promotion
path so existing installers keep receiving the prior bundle). #}
{% if edit_in_flight %}
<a href="#" aria-disabled="true"
title="Wait for the in-flight review to finish before editing.">
Edit (review in flight)
</a>
{% else %}
<a href="/marketplace/flea/{{ entity.id }}/edit"
title="Edit metadata or upload a new version.">
Edit
</a>
{% endif %}
{# v35 delete UX: Archive (soft) is the primary path. Owner sees
Archive only when the entity is approved or already archived
(re-archive is a no-op, but no point exposing). Admin gets
Archive AND Hard Delete (separate red button) regardless of
state. Quarantined (non-approved + non-archived) entities lock
both buttons for the owner — admin still sees both. #}
{% if is_admin %}
{# Archive (soft) only meaningful when the entity is currently
public (approved). For non-approved states the entity is
already hidden — archiving would just lose the quarantine /
pending state info. Admin still has Hard delete + the
override / rescan / retry actions on the quarantine banner
to manage non-approved entities. #}
{% if entity.visibility_status == 'approved' %}
<button class="delete" id="owner-archive-btn" type="button"
title="Soft delete: hides from browse + blocks new installs. Existing user_store_installs continue serving the bundle.">
Archive
</button>
{% elif entity.visibility_status == 'archived' %}
<button class="delete" type="button" disabled
title="Already archived. Hidden from browse; existing installs still served. Use Hard delete to purge.">
Archived
</button>
{% else %}
<button class="delete" type="button" disabled
title="Archive is only available for approved entities. Use Override (in quarantine banner) to publish, Rescan to re-evaluate, or Hard delete to purge.">
Archive (not applicable while {{ entity.visibility_status }})
</button>
{% endif %}
<button class="delete" id="owner-hard-delete-btn" type="button"
style="border-color: rgba(185,28,28,0.45);"
title="Hard delete: drops the bundle from disk + removes existing user_store_installs. Use only for legal / privacy removals — existing users lose the plugin.">
Hard delete (admin)
</button>
{% elif entity.visibility_status == 'approved' %}
<button class="delete" id="owner-archive-btn" type="button"
title="Soft delete: hides from browse + blocks new installs. Existing user_store_installs continue serving the bundle.">
Archive
</button>
{% elif entity.visibility_status == 'archived' %}
<button class="delete" type="button" disabled
title="Already archived. Hidden from browse; existing installs still served. Contact an admin for hard delete.">
Archived
</button>
{% elif entity.visibility_status == 'pending' %}
<button class="delete" type="button" disabled
title="Submission is under review — Delete is locked until checks finish.">
Delete (locked — under review)
</button>
{% else %}
<button class="delete" type="button" disabled
title="Submission is quarantined. Only an admin can delete it (so the failure evidence isn't lost). Edit + re-upload to fix the issues.">
Delete (locked — quarantined)
</button>
{% endif %}
</div>
{% endif %}
{% include "_flea_versions.html" %}
<div class="hero">
<div class="crumbs">
<a href="/marketplace?tab={{ 'curated' if source == 'curated' else 'flea' }}">Marketplace</a>
<span class="sep"></span>
<a href="/marketplace?tab={{ 'curated' if source == 'curated' else 'flea' }}" id="crumb-mid">{{ 'Curated Marketplace' if source == 'curated' else 'Flea Market' }}</a>
<span class="sep"></span>
<span id="crumb-name">{{ (entity.title if entity else None) or plugin_name }}</span>
</div>
<div class="hero-head">
<div class="hero-window" id="hero-photo" aria-hidden="true">
<div class="hero-window-titlebar">
<span class="hwdot red"></span>
<span class="hwdot yellow"></span>
<span class="hwdot green"></span>
<span class="hero-window-label" id="hero-window-label">{{ (entity.title if entity else None) or plugin_name }}</span>
</div>
<div class="hero-window-body" id="hero-window-body">PL</div>
</div>
<div class="meta">
<h1 id="hero-name">{{ (entity.title if entity else None) or plugin_name }}</h1>
<!-- v49 phase-2: hidden by default; JS only un-hides when d.tagline
is truthy. Pre-fix this rendered "Loading…" then collapsed to
empty for entities without a tagline, causing a flicker. -->
<div class="tagline" id="hero-tagline" hidden></div>
<div class="pills" id="hero-pills"></div>
<!-- Telemetry funnel chip — same shape as the listing card so a
user who clicks through from /marketplace sees identical
figures. Visibility decided by JS: hidden when stack_count
== 0 AND invocations_30d == 0 (brand-new plugin, no signals
yet). Lives at the bottom of meta so it reads as the last
"what is this" facet before the action buttons take over. -->
<div class="hero-telemetry" id="hero-telemetry" hidden></div>
</div>
</div>
<!-- Actions absolutely anchored at the hero's top-right corner with
the post-action hint card stacked below them. Both stay inside
the hero gradient — the hero's natural height (window titlebar
+ 715:310 body ≈ 191px) contains them without overflow. -->
<div class="actions">
<div class="actions-row">
<button class="btn-install" id="install-btn" type="button" hidden>+ Add to my stack</button>
<span class="status-pill" id="status-pill" hidden>✓ In your stack</span>
<button class="btn-remove" id="remove-btn" type="button" hidden>✕ Remove from stack</button>
</div>
<div class="stack-hint" id="stack-hint" hidden>
<div class="head">
<span class="title" id="stack-hint-title">✓ Added to your stack</span>
<button class="dismiss" id="stack-hint-dismiss" type="button">Dont show again</button>
</div>
<div>Run in Claude Code:</div>
<div class="cmd-chip">
<span class="prompt">/</span>
<span class="cmd">update-agnes-plugins</span>
<button class="btn-copy" id="stack-hint-copy" type="button">Copy</button>
</div>
</div>
</div>
</div>
<div class="top-row">
<div class="panel" id="panel-what">
<h2>What it does</h2>
<div class="lead" id="lead-text">Loading…</div>
</div>
<div class="panel details" id="panel-details">
<h2>Details</h2>
<dl id="details-list"></dl>
</div>
</div>
<!-- Use cases — populated from marketplace-metadata.json use_cases[]. Hidden
until the curator has supplied at least one card. -->
<div class="panel" id="panel-use-cases" hidden>
<h2>When to use it</h2>
<div class="use-cases-grid" id="panel-use-cases-grid"></div>
</div>
<!-- Sample interaction — single user/assistant Q&A from
marketplace-metadata.json sample_interaction. Hidden until populated. -->
<div class="panel" id="panel-sample" hidden>
<h2>Example</h2>
<div class="sample-interaction">
<div class="sample-user"><span class="sample-label">You</span><div id="sample-user"></div></div>
<div class="sample-assistant"><span class="sample-label">Claude</span><div id="sample-assistant" class="sample-assistant-body"></div></div>
</div>
</div>
<!-- v32: video + doc_links section. Both blocks stay hidden until populated
so the layout collapses gracefully when an upstream marketplace ships
no marketplace-metadata.json. Doc icons differentiate internal-cached files
(📄) from external links (🔗) so the user knows what's a click away. -->
<div class="panel" id="panel-video" hidden>
<h2>Demo video</h2>
<div class="video-embed" id="video-wrap"></div>
</div>
<div class="panel" id="panel-docs" hidden>
<h2>Documentation</h2>
<ul class="doc-link-list" id="doc-link-list"></ul>
</div>
<div class="structure" id="structure" hidden>
<h2>Internal structure</h2>
<div id="struct-skills"></div>
<div id="struct-agents"></div>
<div id="struct-commands"></div>
<div id="struct-hooks"></div>
<div id="struct-mcps"></div>
</div>
<div id="error-msg" class="panel" hidden>
<p class="empty-msg" id="error-text"></p>
</div>
</div>
<script>
'use strict';
(async function(){
{% include "_marketplace_video_embed.html" %}
const root = document.getElementById('root');
const source = root.dataset.source;
const marketplaceId = root.dataset.marketplaceId;
const pluginName = root.dataset.pluginName;
const entityId = root.dataset.entityId;
const apiURL = source === 'curated'
? `/api/marketplace/curated/${encodeURIComponent(marketplaceId)}/${encodeURIComponent(pluginName)}`
: `/api/marketplace/flea/${encodeURIComponent(entityId)}/detail`;
const installURL = source === 'curated'
? `/api/marketplace/curated/${encodeURIComponent(marketplaceId)}/${encodeURIComponent(pluginName)}/install`
: `/api/store/entities/${encodeURIComponent(entityId)}/install`;
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, ch => (
{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]));
}
function fmtBytes(n) {
if (n == null) return '—';
if (n < 1024) return n + ' B';
if (n < 1024*1024) return (n/1024).toFixed(1) + ' KB';
if (n < 1024*1024*1024) return (n/(1024*1024)).toFixed(1) + ' MB';
return (n/(1024*1024*1024)).toFixed(2) + ' GB';
}
// Short integer formatter — matches the listing card's fmtNum so a
// user clicking from /marketplace sees the same shortened figures.
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 fmtRelative(iso) {
if (!iso) return '—';
const t = new Date(iso);
if (isNaN(t)) return iso;
const days = Math.floor((Date.now() - t.getTime()) / 86400000);
if (days <= 0) return 'today';
if (days === 1) return 'yesterday';
if (days < 30) return days + ' days ago';
if (days < 365) return Math.floor(days/30) + ' months ago';
return Math.floor(days/365) + ' years ago';
}
function showError(status) {
document.getElementById('hero-tagline').textContent = '';
document.getElementById('lead-text').textContent = '';
const err = document.getElementById('error-msg');
const txt = document.getElementById('error-text');
if (status === 403) txt.textContent = 'You do not have access to this plugin. Ask your admin to grant your group access.';
else if (status === 404) txt.textContent = 'Plugin not found.';
else txt.textContent = 'Failed to load plugin (' + status + ').';
err.hidden = false;
}
let res;
try { res = await fetch(apiURL); }
catch (e) { showError(0); return; }
if (!res.ok) { showError(res.status); return; }
const d = await res.json();
// ── Hero ────────────────────────────────────────────────────────
// Display name resolution priority:
// 1. marketplace-metadata.json `display_name` (curator-friendly label)
// 2. .claude-plugin/plugin.json `name` (manifest_name — the value
// Claude Code uses for slash-command namespacing)
// 3. URL path's plugin_name (technical id, last-resort fallback)
// Tagline falls back to marketplace.json description (verbose tech text)
// when the curator hasn't filled the friendly tagline yet — preserves
// the historical hero look for un-enriched plugins.
const heroTitle = d.display_name || d.manifest_name || d.plugin_name;
// crumb-mid is rendered server-side as the generic "Curated Marketplace"
// / "Flea Market" link — no JS update needed (was previously overwritten
// with the per-instance marketplace_name, which was opaque to analysts).
document.getElementById('crumb-name').textContent = heroTitle;
document.title = `${heroTitle} — Marketplace`;
document.getElementById('hero-name').textContent = heroTitle;
// v49 phase-2: tagline appears in hero only when explicitly set. Pre-fix
// it fell back to `d.description` (verbose long-form text from frontmatter
// / marketplace.json), which read awkwardly under the h1 and pulled the
// hero too tall. Description still renders in the "What it does" panel
// below the hero.
const tagEl = document.getElementById('hero-tagline');
if (d.tagline) {
tagEl.textContent = d.tagline;
tagEl.hidden = false;
} else {
tagEl.textContent = '';
tagEl.hidden = true;
}
// Window titlebar label mirrors the h1 — keeps the mac-window framing
// consistent with whichever name the user actually sees as the title.
const heroWindowLabel = document.getElementById('hero-window-label');
if (heroWindowLabel) heroWindowLabel.textContent = heroTitle;
// Hero curator line removed — the Details sidebar already carries
// the Curator row (renamed from "Owner"), so duplicating it under
// the h1 was visual noise.
const pills = document.getElementById('hero-pills');
const pillBits = [];
if (d.category) pillBits.push(`<span class="pill cat">${esc(d.category)}</span>`);
if (d.source === 'curated')
pillBits.push(`<span class="pill curated">Curated</span>`);
else
pillBits.push(`<span class="pill flea">Flea</span>`);
const verLabel = d.source === 'curated'
? `${esc(d.marketplace_name || d.marketplace_id)} v${esc(d.version || '')}`
: `v${esc(d.version || '')}`;
if (d.version) pillBits.push(`<span class="pill ver">${verLabel}</span>`);
if (d.updated_at) pillBits.push(`<span class="pill muted">Updated ${esc(fmtRelative(d.updated_at))}</span>`);
pills.innerHTML = pillBits.join('');
// ── Hero telemetry chip ───────────────────────────────────────────
// Identical funnel as the listing card chip — active · calls · trend
// on the left (time-bounded engagement, 30d), installed pinned right
// (passive adoption, all-time). Hidden when both stack_count and
// 30d invocations are zero. Heroicons solid inline so they recolour
// through the per-segment CSS rules above.
(function renderHeroTelemetry() {
const slot = document.getElementById('hero-telemetry');
if (!slot) return;
const tel = d.telemetry || {};
const stackCount = d.stack_count || 0;
const activeUsers = tel.distinct_users_30d || 0;
const calls = tel.invocations_30d || 0;
const trend = tel.trend_pct;
if (stackCount === 0 && calls === 0) { slot.hidden = true; return; }
const svg = (path) =>
`<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" clip-rule="evenodd" d="${path}"/></svg>`;
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");
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");
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");
const ICON_TUP = 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_TDOWN = 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");
// Hero variant — all four segments inline, separated by `·`. The
// listing card splits installed off to the right via flex auto-
// margin (helps a crowded card visually parse the funnel) but
// the hero has plenty of horizontal real estate, so keeping
// installed adjacent to the rest reads as one coherent metadata
// strip instead of two disjoint groups.
const segs = [
`<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 cls = up ? 'trend-up' : 'trend-down';
const icon = up ? ICON_TUP : ICON_TDOWN;
segs.push(`<span class="${cls}" title="Week-over-week change in invocations">${icon} ${Math.abs(Math.round(trend))}%</span>`);
}
segs.push(`<span class="seg-installed" title="Currently installed by ${stackCount} users">${ICON_STACK} ${fmtNum(stackCount)} installed</span>`);
slot.innerHTML = segs.join(' · ');
slot.hidden = false;
})();
// Hero window — macOS-style frame around the cover photo. The titlebar
// label carries the plugin's manifest_name; the body holds the cover
// photo (or a placeholder gradient + initials when no cover is set).
const labelEl = document.getElementById('hero-window-label');
if (labelEl) labelEl.textContent = d.manifest_name || d.plugin_name;
const bodyEl = document.getElementById('hero-window-body');
if (d.cover_photo_url) {
// Same fallback pattern as the marketplace card grid — when the cover
// 404s (missing internal file, or external mirror failed and the
// pass-through URL is dead), restore the initials placeholder so the
// hero looks identical to the no-cover case rather than showing the
// browser's broken-image icon.
const initials = bodyEl.textContent || 'PL';
bodyEl.innerHTML = `<img src="${esc(d.cover_photo_url)}" alt=""
onerror="this.parentElement.classList.add('photo-failed');
this.parentElement.textContent=this.dataset.fallback;"
data-fallback="${esc(initials)}">`;
} else {
bodyEl.textContent = 'PL';
}
// Three-element install state: install button (off-state CTA), status
// pill (on-state label), remove button (on-state action). Only one of
// {installBtn} vs {statusPill + removeBtn} is visible at a time. The
// separation lets the on-state communicate BOTH facts simultaneously
// ("currently in stack" + "click here to remove") without the GitHub
// hover-morph trick — explicit affordance, desktop-first.
const installBtn = document.getElementById('install-btn');
const statusPill = document.getElementById('status-pill');
const removeBtn = document.getElementById('remove-btn');
function renderInstallState(installed) {
installBtn.hidden = installed;
statusPill.hidden = !installed;
statusPill.classList.remove('is-system');
statusPill.textContent = '✓ In your stack';
statusPill.removeAttribute('title');
removeBtn.hidden = !installed;
}
renderInstallState(!!d.installed);
// v32+ quarantine: when the entity is non-approved (only owner +
// admin land here in that state — gated server-side), disable the
// install button with a gray inert style + tooltip. The API also
// refuses POST /install with `entity_not_approved` so a clever user
// who toggles the disabled attribute in devtools still hits a 409.
if (d.visibility_status && d.visibility_status !== 'approved') {
installBtn.hidden = false;
statusPill.hidden = true;
removeBtn.hidden = true;
installBtn.disabled = true;
installBtn.title = 'This submission is not approved yet — install is disabled until checks pass.';
installBtn.textContent = '+ Add to my stack (unavailable while under review)';
installBtn.style.background = '#e5e7eb';
installBtn.style.color = '#6b7280';
installBtn.style.cursor = 'not-allowed';
}
// v39: system plugins are mandatory — already in the user's stack and
// cannot be removed. Render only the locked amber status pill; the
// install + remove controls stay hidden. API also refuses uninstall
// with 409 so a devtools-poke can't bypass the visual lock.
if (d.is_system) {
installBtn.hidden = true;
removeBtn.hidden = true;
statusPill.hidden = false;
statusPill.classList.add('is-system');
statusPill.textContent = '✓ Required by your org';
statusPill.title = 'Required by your organization — managed by admin';
}
// Post-add hint panel — fires only on the *transition* into 'installed'
// and only when the user hasn't permanently dismissed it. The dismiss
// flag lives in localStorage so a returning user who already understands
// the two-step model isn't pestered. Re-shown to nontechnical users
// who hit "+ Add to my stack" for the first time on a new browser/laptop.
const HINT_DISMISS_KEY = 'mp.stack-hint.dismissed.v1';
const hintEl = document.getElementById('stack-hint');
function showHint() {
if (localStorage.getItem(HINT_DISMISS_KEY) === '1') return;
hintEl.hidden = false;
}
document.getElementById('stack-hint-dismiss').addEventListener('click', () => {
localStorage.setItem(HINT_DISMISS_KEY, '1');
hintEl.hidden = true;
});
document.getElementById('stack-hint-copy').addEventListener('click', async (ev) => {
const copyBtn = ev.currentTarget;
try {
await navigator.clipboard.writeText('/update-agnes-plugins');
const orig = copyBtn.textContent;
copyBtn.classList.add('copied');
copyBtn.textContent = 'Copied';
setTimeout(() => { copyBtn.textContent = orig; copyBtn.classList.remove('copied'); }, 1500);
} catch { /* clipboard blocked — chip text remains selectable */ }
});
// Two click handlers — one per element. Both surface the same
// /update-agnes-plugins recipe afterwards because the local Claude
// Code session needs the same refresh whether the user just added
// OR removed the plugin from the served set. Title swaps between
// "Added" / "Removed" so the reason for the recipe is unambiguous.
const hintTitle = document.getElementById('stack-hint-title');
function setHintTitle(kind) {
if (!hintTitle) return;
hintTitle.textContent = kind === 'removed'
? '✓ Removed from your stack'
: '✓ Added to your stack';
}
installBtn.addEventListener('click', async () => {
const r = await fetch(installURL, { method: 'POST' });
if (!r.ok) { alert('Add failed (' + r.status + ')'); return; }
renderInstallState(true);
setHintTitle('added');
showHint();
});
removeBtn.addEventListener('click', async () => {
const r = await fetch(installURL, { method: 'DELETE' });
if (!r.ok) { alert('Remove failed (' + r.status + ')'); return; }
renderInstallState(false);
setHintTitle('removed');
showHint();
});
// v35 owner / admin delete handlers. Two paths:
// * Archive (soft) — DELETE /api/store/entities/{id}, default body.
// Hides from browse, blocks new installs, KEEPS existing
// user_store_installs serving the bundle.
// * Hard delete (admin only) — DELETE /api/store/entities/{id}?hard=true.
// Drops the bundle from disk + removes existing installs.
// Existing users lose the plugin on next sync. Confirmation
// mentions the install count so admin doesn't nuke a popular
// plugin by accident.
function bindDelete(id, opts) {
const btn = document.getElementById(id);
if (!btn || root.dataset.source !== 'flea' || !root.dataset.entityId) return;
btn.addEventListener('click', async () => {
if (!confirm(opts.confirm)) return;
const url = `/api/store/entities/${encodeURIComponent(root.dataset.entityId)}${opts.hard ? '?hard=true' : ''}`;
const r = await fetch(url, { method: 'DELETE' });
if (!r.ok) {
alert((opts.hard ? 'Hard delete' : 'Archive') + ' failed (' + r.status + ')');
return;
}
window.location = '/marketplace?tab=flea';
});
}
bindDelete('owner-archive-btn', {
hard: false,
confirm: 'Archive this entity? It disappears from browse + nobody can install it. Existing installs keep working.',
});
bindDelete('owner-hard-delete-btn', {
hard: true,
confirm: 'HARD DELETE — this drops the bundle and removes ALL existing installs. Users who already added it will lose the plugin on next sync. Continue?',
});
// ── What it does ────────────────────────────────────────────────
// When the curator authored a rich markdown body in marketplace-metadata.json,
// the API renders + sanitizes it server-side and ships it in
// `description_long_html`. We inject as HTML. Falling back to the plain
// marketplace.json `description` preserves the historical render path for
// plugins whose curator hasn't filled the new field yet.
const lead = document.getElementById('lead-text');
if (d.description_long_html && d.description_long_html.trim()) {
lead.innerHTML = d.description_long_html;
lead.classList.add('lead-rendered');
} else if (d.description && d.description.trim()) {
lead.textContent = d.description;
} else {
document.getElementById('panel-what').hidden = true;
}
// ── Kdy to použiju (Use cases) ──────────────────────────────────
// marketplace-metadata.json :: use_cases[] — one card per entry. Hidden
// until populated; un-enriched plugins skip this section entirely.
const useCasesEl = document.getElementById('panel-use-cases-grid');
const useCasesPanel = document.getElementById('panel-use-cases');
if (useCasesEl && Array.isArray(d.use_cases) && d.use_cases.length) {
useCasesEl.innerHTML = d.use_cases.map(uc => `
<div class="use-case-card">
<h3>${esc(uc.title)}</h3>
<p>${esc(uc.description)}</p>
<pre class="use-case-prompt"><code>${esc(uc.prompt)}</code></pre>
</div>
`).join('');
useCasesPanel.hidden = false;
}
// ── Ukázka konverzace (Sample interaction) ──────────────────────
// marketplace-metadata.json :: sample_interaction — {user, assistant_html}.
// Assistant body was rendered + sanitized server-side; we inject as HTML.
const sampleEl = document.getElementById('panel-sample');
if (sampleEl && d.sample_interaction
&& d.sample_interaction.user && d.sample_interaction.assistant_html) {
document.getElementById('sample-user').textContent = d.sample_interaction.user;
document.getElementById('sample-assistant').innerHTML = d.sample_interaction.assistant_html;
sampleEl.hidden = false;
}
// ── v32: demo video (marketplace-metadata.json :: video_url) ─────────
// Detection logic lives in the shared partial so item_detail.html and
// this page stay in lockstep on YouTube nocookie / Vimeo / mp4 / link
// fallback handling.
if (d.video_url) {
document.getElementById('video-wrap').innerHTML =
buildVideoEmbed(d.video_url, esc);
document.getElementById('panel-video').hidden = false;
}
// ── v32: doc_links (marketplace-metadata.json :: doc_links[]) ────────
// The user contract: every entry in this list is a real downloadable
// document Agnes can serve (PDF / Markdown / plain text). Sync pipeline
// already dropped HTML pages, 404s, SSRF-blocked URLs, and any internal
// path with the wrong extension — what remains is one shape: clickable
// link, browser starts a download. No badges, no chips, no source
// distinction in the UI (where the file lives is a sync-time concern,
// not something the analyst clicking the link cares about).
//
// The `download` attribute is honored for same-origin URLs (which all of
// ours are — both /doc/ and /mirrored/ are Agnes endpoints). The server
// also sets Content-Disposition: attachment, so even cross-origin tools
// that ignore the attribute still trigger a download.
const docLinks = (d.docs && d.docs.length) ? d.docs : [];
if (docLinks.length) {
const list = document.getElementById('doc-link-list');
list.innerHTML = docLinks.map(doc => {
const url = doc.url || '#';
return `<li>
<a href="${esc(url)}" download>${esc(doc.name)}</a>
</li>`;
}).join('');
document.getElementById('panel-docs').hidden = false;
}
// ── Details ─────────────────────────────────────────────────────
// v49 phase-7: unified scan order across plugin + skill/agent detail
// surfaces. Priority: identity (who) → life-stage (when) → telemetry
// (how used) → debug-tier (size). Slug field dropped — debug info,
// not user-facing.
//
// Render order:
// 1. Curator / Owner — first scan signal (trust)
// 2. Released — life-stage
// 3. Last used — recency (higher priority than Active days)
// 4. Active days — engagement consistency over 30d
// 5. Bundle size — debug-tier
//
// Each row is conditional on data presence so the panel stays compact.
const detailRows = [];
// 1. Curator/Owner. Curated → d.author_name (with owner_todo placeholder
// surfaced as visible reminder). Flea → d.owner_display (fullname from
// users.name → users.email → owner_username chain, populated by
// _resolve_owner_display in app/api/marketplace.py).
if (d.source === 'curated') {
if (d.author_name && d.author_name !== 'owner_todo') {
detailRows.push(`<div class="row"><dt>Curator</dt><dd>${esc(d.author_name)}</dd></div>`);
} else {
detailRows.push(`<div class="row"><dt>Curator</dt><dd><span class="todo">owner_todo</span></dd></div>`);
}
} else if (d.owner_display) {
detailRows.push(`<div class="row"><dt>Owner</dt><dd>${esc(d.owner_display)}</dd></div>`);
}
// 2. Released.
if (d.released_at) {
detailRows.push(`<div class="row"><dt>Released</dt><dd>${esc(fmtRelative(d.released_at))}</dd></div>`);
}
// 3-4. Telemetry-derived rows. Invocations + Users live in the hero
// chip; sidebar carries two derived signals only the daily series can
// give us. Last used first (recency), Active days second (consistency).
if (d.telemetry && Array.isArray(d.telemetry.daily_series)) {
const series = d.telemetry.daily_series;
const activeDays = series.filter(p => (p.invocations || 0) > 0).length;
if (activeDays > 0) {
// Last day with at least one invocation. Series is day-ascending,
// so the final non-zero entry is the latest active day.
let lastIso = null;
for (let i = series.length - 1; i >= 0; i--) {
if ((series[i].invocations || 0) > 0) { lastIso = series[i].day; break; }
}
if (lastIso) {
detailRows.push(
`<div class="row"><dt>Last used</dt><dd>${esc(fmtRelative(lastIso))}</dd></div>`
);
}
detailRows.push(
`<div class="row"><dt>Active days</dt><dd>${activeDays} of ${series.length}</dd></div>`
);
}
}
// 5. Bundle size.
if (d.bundle_size != null) {
detailRows.push(`<div class="row"><dt>Bundle size</dt><dd>${esc(fmtBytes(d.bundle_size))}</dd></div>`);
}
const detailsEl = document.getElementById('details-list');
if (detailRows.length) {
detailsEl.innerHTML = detailRows.join('');
} else {
document.getElementById('panel-details').hidden = true;
}
// ── Internal structure ─────────────────────────────────────────
// Funnel chip on the inner skill/agent card — mirrors the marketplace
// listing card chip (same four segments, same icons, same gate). Inner
// items can't be installed standalone, so the "installed" segment reads
// the parent plugin's adoption count (curated stack_count or flea
// install_count, populated server-side as parent_stack_count).
const _innerSvg = (path) =>
`<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" clip-rule="evenodd" d="${path}"/></svg>`;
const INNER_ICON_STACK = _innerSvg("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");
const INNER_ICON_USER = _innerSvg("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");
const INNER_ICON_BOLT = _innerSvg("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");
const INNER_ICON_TUP = _innerSvg("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 INNER_ICON_TDOWN = _innerSvg("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");
function buildInnerCardChip(it) {
const stackCount = it.parent_stack_count || 0;
const activeUsers = it.distinct_users_30d || 0;
const calls = it.invocations_30d || 0;
const trend = it.trend_pct;
// Same gate as the listing card / hero chip — zero adoption AND
// zero 30d activity means brand-new bundle, don't visually penalise
// it with a "0 · 0 · 0" row.
if (stackCount === 0 && calls === 0) return '';
const leftSegs = [
`<span class="seg-active" title="${activeUsers} users invoked it in the last 30 days">${INNER_ICON_USER} ${fmtNum(activeUsers)} active</span>`,
`<span class="seg-calls" title="${calls} invocations in the last 30 days">${INNER_ICON_BOLT} ${fmtNum(calls)} calls</span>`,
];
if (trend !== null && trend !== undefined) {
const up = trend >= 0;
const icon = up ? INNER_ICON_TUP : INNER_ICON_TDOWN;
const cls = up ? 'trend-up' : 'trend-down';
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="Parent plugin currently installed by ${stackCount} users">${INNER_ICON_STACK} ${fmtNum(stackCount)} installed</span>`;
return `<div class="inv-chip">
<span class="inv-chip-left">${leftSegs.join(' · ')}</span>
${installedSeg}
</div>`;
}
function buildCardSection(title, items, type) {
if (!items || !items.length) return '';
// Inner cards render the marketplace-metadata cover photo when present
// (mirrored OK for external, file exists for internal); otherwise the
// initials fall through ("SK" / "AG") on the colored gradient. Same
// onerror fallback as the top-level cards so a missing file doesn't
// produce the browser's broken-image icon.
const initials = type === 'skill' ? 'SK' : 'AG';
const cards = items.map(it => {
const photoMarkup = it.cover_photo_url
? `<div class="photo"><img src="${esc(it.cover_photo_url)}" alt=""
onerror="this.parentElement.classList.add('photo-failed');
this.parentElement.textContent='${initials}';"></div>`
: `<div class="photo">${initials}</div>`;
return `
<a class="inner-card" data-type="${type}" href="${esc(it.detail_url || '#')}">
${photoMarkup}
<div class="body">
<span class="type-badge">${type}</span>
<div class="name">${esc(it.name)}</div>
<div class="desc">${esc(it.description || '')}</div>
${buildInnerCardChip(it)}
</div>
</a>`;
}).join('');
return `
<div class="substruct">
<div class="head">
<h3>${title}</h3>
<span class="count">${items.length} ${type}${items.length === 1 ? '' : 's'}</span>
</div>
<div class="inner-grid">${cards}</div>
</div>`;
}
function buildTableSection(title, items, columns) {
if (!items || !items.length) return '';
const head = columns.map(c => `<th${c.width ? ' style="width:'+c.width+'px"' : ''}>${esc(c.label)}</th>`).join('');
const rows = items.map(it => columns.map(c => {
const v = it[c.key];
if (c.cls === 'cell-name') return `<td class="cell-name">${esc(v || '')}</td>`;
if (c.cls === 'cell-event' || c.cls === 'cell-type') return `<td class="${c.cls}">${esc(v || '—')}</td>`;
return `<td class="cell-desc">${esc(v || '')}</td>`;
}).join('')).map(tr => `<tr>${tr}</tr>`).join('');
return `
<div class="substruct">
<div class="head">
<h3>${title}</h3>
<span class="count">${items.length} ${title.toLowerCase()}</span>
</div>
<table>
<thead><tr>${head}</tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
}
const hasAny = (d.skills && d.skills.length)
|| (d.agents && d.agents.length)
|| (d.commands && d.commands.length)
|| (d.hooks && d.hooks.length)
|| (d.mcps && d.mcps.length);
if (hasAny) {
document.getElementById('structure').hidden = false;
document.getElementById('struct-skills').innerHTML = buildCardSection('Skills', d.skills, 'skill');
document.getElementById('struct-agents').innerHTML = buildCardSection('Agents', d.agents, 'agent');
document.getElementById('struct-commands').innerHTML = buildTableSection('Commands', d.commands, [
{ key: 'name', label: 'Name', cls: 'cell-name', width: 220 },
{ key: 'description', label: 'Description' },
]);
document.getElementById('struct-hooks').innerHTML = buildTableSection('Hooks', d.hooks, [
{ key: 'name', label: 'Name', cls: 'cell-name', width: 220 },
{ key: 'event', label: 'Event', cls: 'cell-event', width: 180 },
{ key: 'description', label: 'Description' },
]);
document.getElementById('struct-mcps').innerHTML = buildTableSection('MCP servers', d.mcps, [
{ key: 'name', label: 'Name', cls: 'cell-name', width: 220 },
{ key: 'type', label: 'Type', cls: 'cell-type', width: 180 },
{ key: 'description', label: 'Description' },
]);
}
})();
</script>
{% endblock %}