* 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>
1535 lines
71 KiB
HTML
1535 lines
71 KiB
HTML
{% extends "base.html" %}
|
||
{% block title %}{{ 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">{{ 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">{{ plugin_name }}</span>
|
||
</div>
|
||
<div class="hero-window-body" id="hero-window-body">PL</div>
|
||
</div>
|
||
<div class="meta">
|
||
<h1 id="hero-name">{{ plugin_name }}</h1>
|
||
<div class="tagline" id="hero-tagline">Loading…</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">Don’t 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 => (
|
||
{'&':'&','<':'<','>':'>','"':'"',"'":'''}[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;
|
||
document.getElementById('hero-tagline').textContent = d.tagline || d.description || '';
|
||
// 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 ─────────────────────────────────────────────────────
|
||
// Render only rows that have a real value — missing/null/owner_todo
|
||
// entries get hidden so the panel stays compact.
|
||
const detailRows = [];
|
||
const slugVal = d.source === 'curated' ? d.marketplace_id : d.entity_id;
|
||
if (slugVal) {
|
||
detailRows.push(`<div class="row"><dt>Slug</dt><dd class="mono">${esc(slugVal)}</dd></div>`);
|
||
}
|
||
if (d.released_at) {
|
||
detailRows.push(`<div class="row"><dt>Released</dt><dd>${esc(fmtRelative(d.released_at))}</dd></div>`);
|
||
}
|
||
if (d.bundle_size != null) {
|
||
detailRows.push(`<div class="row"><dt>Bundle size</dt><dd>${esc(fmtBytes(d.bundle_size))}</dd></div>`);
|
||
}
|
||
// Activity sidebar rows — Invocations + Users were moved into the
|
||
// hero chip above (no need to duplicate the same figures twice on
|
||
// one page). The sidebar now carries two *derived* signals from the
|
||
// daily series that the hero doesn't expose:
|
||
// * Active days — engagement consistency over the 30d window.
|
||
// "Active on 18 of 30 days" reads very differently from
|
||
// "Active on 2 of 30 days" even when the raw call count is
|
||
// similar; helps spot bursty vs. steady usage.
|
||
// * Last used — recency, answering "is it still alive?". A
|
||
// plugin with 12 installed users but last call 30 days ago is
|
||
// stale; the hero chip can't surface that alone.
|
||
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) {
|
||
detailRows.push(
|
||
`<div class="row"><dt>Active days</dt><dd>${activeDays} of ${series.length}</dd></div>`
|
||
);
|
||
// Last day with at least one invocation. Series is day-ascending
|
||
// (per _load_plugin_daily_series), so the final non-zero entry is
|
||
// the latest active day. Render relative ("2 days ago" / "Active
|
||
// today") via the same fmtRelative helper used elsewhere.
|
||
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>`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
// Authorship row label tracks the source: curated bundles have a
|
||
// curator (the marketplace operator), flea bundles have an owner
|
||
// (the analyst who uploaded). When `author_name` is the `owner_todo`
|
||
// placeholder the curated branch surfaces it visibly as a reminder
|
||
// to wire up curator metadata; flea falls through silently.
|
||
const authorLabel = d.source === 'curated' ? 'Curator' : 'Owner';
|
||
if (d.author_name && d.author_name !== 'owner_todo') {
|
||
detailRows.push(`<div class="row"><dt>${authorLabel}</dt><dd>${esc(d.author_name)}</dd></div>`);
|
||
} else if (d.source === 'curated') {
|
||
detailRows.push(`<div class="row"><dt>Curator</dt><dd><span class="todo">owner_todo</span></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 %}
|