agnes-the-ai-analyst/app/web/templates/marketplace_item_detail.html
Monika Feigler caae12d02f
fix(web): UI consistency — code tokens, label-qualifier, radio cards, Keboola edit-modal JS (#347)
* fix(web): UI consistency — code tokens, label-qualifier, radio card selected state

I-UI-01: Add .sync-option-card:has(input:checked) rule — border + background
feedback when a radio option card is selected. Add class sync-option-card to
all 14 radio label cards in admin_tables.html.

I-UI-02: Add .label-qualifier / .optional to style-custom.css. Remove the
duplicate local definition from admin_tables.html <style> block.

I-UI-03: Migrate inline code rule to design tokens (--font-mono, --text-sm,
--border-light, --border, --radius-sm). Add background + border so inline
code is visually distinct across all pages.

I-UI-05 (partial): Replace hardcoded #c4c4c4 / #fafafa in .btn-google:hover
with var(--border) / var(--background) so theme overrides apply.

* fix(web): expose entire Keboola edit-modal JS to all instance types

openEditKeboolaModal, closeEditKeboolaModal, saveKeboolaTabEdit,
onEditKbStrategyChange and helpers were still inside {% if keboola %}
but called from always-rendered HTML (openEditModal dispatcher,
Escape key handler, modal overlay click, Cancel/Save buttons).

Removed the Phase F2 if-guard entirely — only prefillFromKeboolaTable
stays conditional (its callers are inside {% if keboola %} HTML blocks).

* fix(ui): promote .form-textarea to global CSS with design tokens

Removes the local hardcoded .form-textarea definition from admin_tables.html
and adds it globally to style-custom.css using design tokens, making
description textareas visually consistent with other form fields.

* fix(ui): restore .form-textarea to local style block for visual consistency

Tokens --text-sm (12px) and --radius-md (6px) differ from the local override
values (13px, 8px) used by .form-input on this page, causing a visible mismatch.
.form-textarea rejoins the shared local selector so all three classes render
identically; global .form-textarea in style-custom.css remains as a baseline
for other pages.

* fix(ui): use textarea.form-textarea in global CSS to override .form-group textarea

.form-group textarea (specificity 0,1,1) was overriding .form-textarea (0,1,0)
with a legacy monospace font and different padding. Raising the selector to
textarea.form-textarea matches specificity and wins via source order, making
description textareas consistent with other form inputs. Local admin_tables.html
overrides for .form-textarea removed — styling now comes entirely from global CSS.

* fix(ui): add border:none to .code-block code + add CHANGELOG entries

Fixes light-gray border leaking into dark .code-block backgrounds.
Adds required CHANGELOG.md entries for all user-visible changes in this PR.

* fix(ui): add --border-dark token + reset border-radius in .code-block code

- Adds --border-dark: #C4C4C4 design token for hover border states
- Uses var(--border-dark) in both .btn-google:hover rules so hover border
  is visually distinct from the base border (was a no-op with var(--border))
- Adds border-radius: 0 to .code-block code override to fully reset the
  new global code border-radius on dark code-block backgrounds

* fix(ui): reset code border/bg inside .use-case-prompt dark container

Adds .plugin-detail .use-case-prompt code override to prevent the new
global code border and background from leaking into the dark #1e1e2e
pre block in marketplace_plugin_detail.html.

* fix(ui): reset code border in all dark-background containers

Global code { border } leaks into dark-themed containers across templates.
Adds border: none (+ border-radius: 0 where needed) to:
- marketplace_plugin_detail.html: lead-rendered pre code, sample-assistant-body code/pre code
- marketplace_item_detail.html: same three selectors
- home_onboarded.html, home_not_onboarded.html, admin_welcome.html: inline code on hero dark backgrounds

* fix(ui): uniform form typography — chip-input font, data-package desc textarea, orphan endif

- .chip-input container gets font-family/size tokens so inner input
  inherits correctly (inline `font: inherit` was pulling browser default)
- cdp-desc / edp-desc switched from form-input to form-textarea so
  description fields render Inter, not monospace
- Removed orphan {% endif %} left in admin_tables.html after rebase
  (caused TemplateSyntaxError breaking all admin-tables tests in CI)
- .item-detail .use-case-prompt code: border/bg reset for dark container

* fix: relax test_keboola_discover_buttons assertion + CHANGELOG bullet for #347

The test_keboola_discover_buttons_hidden_on_bigquery_instance test
asserted bare-string `prefillFromKeboolaTable` not in the rendered
HTML on a non-Keboola instance. That made sense when the function
DEFINITION lived behind the keboola Jinja guard. #347 moves
several Keboola edit-modal helpers out from under the guard so
they're now defined as dead code on every instance, but the actual
call sites (`onclick="prefillFromKeboolaTable(...)"` + the
Discover buttons themselves) still respect the guard — which is
what actually matters for runtime behavior.

Updated the assertions to match `onclick="<fn>(` so they pin the
call-site contract, not the function-definition substring.

---------

Co-authored-by: ZdenekSrotyr <zdenek.srotyr@keboola.com>
2026-05-19 16:30:19 +02:00

1558 lines
70 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

{% extends "base.html" %}
{% block title %}{{ (entity.title if entity else None) or inner_name or item_name or plugin_name }} — {{ config.INSTANCE_NAME }}{% endblock %}
{% block content %}
<style>
.item-detail {
--bg: var(--background);
--warn-color: #b45309;
}
/* Per-kind accents — driven from data-kind. `--kind-from` / `--kind-to`
are the gradient endpoints for the dark hero, mirroring how
plugin_detail uses #0073D1 → #0056A3. The lighter shade leads
(top-left), darker trails (bottom-right). */
.item-detail[data-kind="skill"] {
--kind: #0e9b6a;
--kind-from: #10b77f;
--kind-to: #047857;
--kind-bg: rgba(16, 183, 127, 0.14);
--kind-shadow: rgba(16, 183, 127, 0.22);
}
.item-detail[data-kind="agent"] {
--kind: #6d28d9;
--kind-from: #7c3aed;
--kind-to: #5b21b6;
--kind-bg: rgba(124, 58, 237, 0.14);
--kind-shadow: rgba(124, 58, 237, 0.22);
}
/* ── Hero — full kind-coloured gradient (parity with plugin detail) ─ */
.item-detail .hero {
position: relative;
background: linear-gradient(135deg, var(--kind-from) 0%, var(--kind-to) 100%);
border-radius: 14px;
padding: 22px 28px 28px;
margin-bottom: 24px;
box-shadow: 0 4px 16px var(--kind-shadow);
color: #fff;
}
/* Breadcrumbs — moved INSIDE hero, white-tinted (parity with plugin detail). */
.item-detail .hero .crumbs {
display: flex; gap: 6px; align-items: center; flex-wrap: wrap;
font-size: 12px; color: rgba(255,255,255,0.78);
margin-bottom: 18px;
}
.item-detail .hero .crumbs a { color: #fff; opacity: 0.92; text-decoration: none; }
.item-detail .hero .crumbs a:hover { text-decoration: underline; }
.item-detail .hero .crumbs .sep { opacity: 0.5; }
.item-detail .hero .crumbs .current { color: #fff; font-weight: var(--font-medium); }
.item-detail .hero .head {
display: grid;
grid-template-columns: 380px minmax(0, 1fr) 300px;
gap: 24px;
align-items: start;
}
@media (max-width: 1100px) {
.item-detail .hero .head { grid-template-columns: 380px minmax(0, 1fr); }
}
@media (max-width: 720px) {
.item-detail .hero .head { grid-template-columns: 1fr; }
}
/* Hero window — macOS-style frame around the skill/agent cover photo.
Mirrors the plugin-detail hero window so the curated/flea detail
pages share a consistent visual language. The window's dark chrome
stays constant; only the surrounding hero gradient changes per kind
(skill=green, agent=purple). The body has aspect-ratio 715/310 so
curator-uploaded covers never crop to a square. */
.item-detail .hero .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;
}
.item-detail .hero .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);
}
.item-detail .hero .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;
}
.item-detail .hero .hwdot.red { background: #ff5f56; }
.item-detail .hero .hwdot.yellow { background: #ffbd2e; }
.item-detail .hero .hwdot.green { background: #27c93f; }
.item-detail .hero .hero-window-label {
margin: 0 auto;
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%;
}
.item-detail .hero .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: var(--font-bold);
letter-spacing: 1px;
}
.item-detail .hero .hero-window-body img {
width: 100%; height: 100%; object-fit: cover; display: block;
}
.item-detail .hero .meta {
min-width: 0;
}
/* Pills — name + CSS structure copied from marketplace_plugin_detail.html
so plugin / skill / agent hero badges are visually identical except
for the per-item variants. Item-only addition: `.pill.type` (Skill /
Agent uppercase label) — plugin detail has no kind axis. */
.item-detail .hero .pills {
display: flex; gap: 6px; flex-wrap: wrap; align-items: center;
margin-top: 12px;
}
.item-detail .pill {
background: rgba(255,255,255,0.16); color: #fff;
padding: 3px 10px; border-radius: 999px;
font-size: 11px; font-weight: 500;
}
.item-detail .pill.cat { background: rgba(255,255,255,0.22); }
.item-detail .pill.ver { font-family: var(--font-mono); }
.item-detail .pill.curated { background: #FEF3C7; color: #B45309; font-weight: 600; }
.item-detail .pill.flea { background: #EDE9FE; color: #6D28D9; font-weight: 600; }
.item-detail .pill.muted { background: transparent; color: rgba(255,255,255,0.72); padding-left: 0; }
.item-detail .pill.type {
background: rgba(255,255,255,0.22); color: #fff;
font-weight: var(--font-semibold);
text-transform: uppercase; letter-spacing: 0.5px;
}
/* Hero telemetry chip — same shape as the plugin detail page, with
a "Plugin:" prefix on the installed segment because skills/agents
show the *parent plugin's* stack count (no per-skill subscription
model exists). Recoloured for the kind-tinted hero background. */
.item-detail .hero .hero-telemetry {
margin-top: 12px;
font-size: 12.5px;
color: rgba(255,255,255,0.92);
line-height: 1.7;
}
.item-detail .hero .hero-telemetry > span {
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 4px;
}
.item-detail .hero .hero-telemetry svg {
width: 14px; height: 14px;
flex-shrink: 0;
}
/* Icon colors mirror the plugin detail page so the four-segment
chip reads identically across plugin + skill/agent surfaces. */
.item-detail .hero .hero-telemetry .seg-active > svg { color: #6ee7b7; }
.item-detail .hero .hero-telemetry .seg-calls > svg { color: #fdba74; }
.item-detail .hero .hero-telemetry .seg-installed > svg { color: #fde68a; }
.item-detail .hero .hero-telemetry .seg-installed .seg-installed-label {
opacity: 0.78;
font-weight: 600;
margin-right: 2px;
}
.item-detail .hero .hero-telemetry .trend-up { color: #6ee7b7; font-weight: 600; }
.item-detail .hero .hero-telemetry .trend-down { color: #fca5a5; font-weight: 600; }
.item-detail .hero h1 {
margin: 0 0 6px; font-size: 28px; font-weight: var(--font-bold);
letter-spacing: -0.4px; color: #fff;
word-wrap: break-word;
}
.item-detail .hero .meta-row {
display: flex; gap: 10px; flex-wrap: wrap; align-items: center;
font-size: 13px; color: rgba(255,255,255,0.85);
}
.item-detail .hero .meta-row strong { color: #fff; font-weight: var(--font-semibold); }
.item-detail .hero .meta-row a { color: #fff; text-decoration: none; }
.item-detail .hero .meta-row a:hover { text-decoration: underline; }
.item-detail .hero .meta-row .dot { color: rgba(255,255,255,0.4); }
/* Invocation block — flea only. Lives inside the Description panel so
"what it does" sits next to "how to call it". The chip itself matches
the .code-block convention from /setup (Catppuccin Mocha palette, mono,
green `/` prompt + Copy button) so it reads as a real terminal command. */
.item-detail .invocation-block { margin-top: 18px; }
.item-detail .invocation-label {
font-size: 12px; font-weight: var(--font-semibold);
color: var(--text-secondary);
text-transform: uppercase; letter-spacing: 0.6px;
margin: 0 0 8px;
}
.item-detail .invocation {
display: flex; align-items: center; gap: 12px;
padding: 10px 14px;
background: #1e1e2e;
border-radius: 8px;
font-family: var(--font-mono); font-size: 13px;
color: #cdd6f4;
width: 100%;
line-height: 1.5;
box-sizing: border-box;
}
.item-detail .invocation .prompt {
color: #a6e3a1;
user-select: none;
flex-shrink: 0;
font-weight: var(--font-bold);
}
.item-detail .invocation .cmd { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.item-detail .invocation .btn-copy {
appearance: none; cursor: pointer;
padding: 4px 10px;
background: transparent;
border: 1px solid #45475a;
color: #cdd6f4;
border-radius: 6px;
font-size: 11px; font-weight: var(--font-medium);
font-family: var(--font-primary);
transition: all 0.15s ease;
flex-shrink: 0;
}
.item-detail .invocation .btn-copy:hover {
border-color: #89b4fa; color: #89b4fa;
background: rgba(137, 180, 250, 0.08);
}
.item-detail .invocation .btn-copy.copied {
border-color: #a6e3a1; color: #a6e3a1;
background: rgba(166, 227, 161, 0.08);
}
.item-detail .hero .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) {
.item-detail .hero .actions {
position: static; width: auto; margin-top: 18px;
align-items: flex-end;
}
}
.item-detail .hero .actions .actions-row {
display: flex; flex-direction: row; align-items: center;
justify-content: flex-end; gap: 12px;
}
.item-detail .hero .actions [hidden] { display: none !important; }
.item-detail .hero .actions .btn {
appearance: none; cursor: pointer;
padding: 9px 16px; border-radius: 8px;
font-size: 12px; font-weight: var(--font-semibold); font-family: inherit;
border: 1px solid rgba(255,255,255,0.28);
background: rgba(255,255,255,0.12);
color: #fff;
text-decoration: none;
transition: all 0.15s ease;
}
.item-detail .hero .actions .btn:hover {
/* Darken instead of lightening — white text on a brighter white
background was washing out. */
background: rgba(0, 0, 0, 0.2);
border-color: rgba(255, 255, 255, 0.55);
color: #fff;
}
.item-detail .hero .actions .btn.primary {
background: #fff; color: var(--kind); border-color: #fff;
}
.item-detail .hero .actions .btn.primary:hover {
/* Darken-glass — same formula as the secondary .btn:hover above so
primary and secondary hero actions feel consistent. The kind
gradient shows through the 20% black tint, the white border + text
supply contrast on either green (skill) or purple (agent) heroes. */
background: rgba(0, 0, 0, 0.2);
border-color: rgba(255, 255, 255, 0.55);
color: #fff;
}
/* Status label — inline text, NOT a button. Communicates "currently
in stack" sitting before the Remove button. No border, no fill —
visual weight intentionally below the adjacent action so the
user's eye lands on the button. */
.item-detail .hero .actions .status-pill {
display: inline-flex; align-items: center;
font-size: 13px; font-weight: 500;
color: #fff;
cursor: default; user-select: none;
}
/* Remove from stack — red border by default so the destructive
intent is announced before hover; full red fill on hover commits
to it. */
.item-detail .hero .actions .btn-remove {
appearance: none; cursor: pointer;
/* Padding + font-size match .btn.primary so the install / remove
buttons render at the same height across state transitions. */
padding: 9px 16px; border-radius: 8px;
font-size: 12px; font-weight: var(--font-semibold); font-family: inherit;
background: transparent; color: #fecaca;
border: 1px solid rgba(248, 113, 113, 0.7);
transition: all 0.15s ease;
}
.item-detail .hero .actions .btn-remove:hover {
background: rgba(220, 38, 38, 0.85); color: #fff;
border-color: rgba(220, 38, 38, 0.95);
}
.item-detail .hero .actions .btn-remove:focus-visible {
outline: 2px solid rgba(254, 202, 202, 0.85); outline-offset: 2px;
}
.item-detail .hero .actions .helper {
font-size: 11px; color: rgba(255,255,255,0.78); line-height: 1.45;
text-align: right; max-width: 260px;
/* Anchor the 260px box at the right edge of the 300px .actions column
so the helper's right edge lines up with the button's right edge —
without this the parent's `align-items: stretch` puts the box flush
LEFT, and text-align: right only right-aligns within the 260px box,
which sits ~40px short of the button. */
align-self: flex-end;
}
.item-detail .hero .actions .helper strong {
color: #fff; font-weight: var(--font-semibold);
}
/* ── Post-add hint panel (flea standalone only) ──────────────────────
Mirrors the panel on marketplace_plugin_detail.html. Lives inside
the Description panel below the invocation chip so the user sees
"what it does → how to call it → what to do to make it callable"
in the natural reading order. */
/* Glass-on-gradient inside the hero — translucent white over the
kind gradient (green for skill, purple for agent) reads as
"elevated tile of the same hero". White text + dismiss outline
work on any of the three kind palettes without per-kind branching. */
.item-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);
}
.item-detail .stack-hint .head {
display: flex; align-items: center; justify-content: space-between;
gap: 8px; margin-bottom: 4px;
}
.item-detail .stack-hint .title {
font-weight: var(--font-semibold);
color: #fff;
font-size: 12px;
}
.item-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;
}
.item-detail .stack-hint .dismiss:hover {
color: #fff; background: rgba(255, 255, 255, 0.14);
border-color: rgba(255, 255, 255, 0.50);
}
.item-detail .stack-hint ol {
margin: 6px 0 0; padding-left: 20px;
color: var(--text-secondary);
}
.item-detail .stack-hint ol li { margin: 4px 0; }
.item-detail .stack-hint ol li strong { color: var(--text-primary); font-weight: var(--font-semibold); }
.item-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;
}
.item-detail .stack-hint .cmd-chip .prompt {
color: #a6e3a1; user-select: none; font-weight: var(--font-bold);
}
.item-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;
}
.item-detail .stack-hint .cmd-chip .btn-copy:hover {
border-color: #89b4fa; color: #89b4fa;
background: rgba(137, 180, 250, 0.08);
}
.item-detail .stack-hint .cmd-chip .btn-copy.copied {
border-color: #a6e3a1; color: #a6e3a1;
}
.item-detail .stack-hint .learn-more {
display: inline-block; margin-top: 8px;
font-size: 12px; color: var(--primary); text-decoration: none;
}
.item-detail .stack-hint .learn-more:hover { text-decoration: underline; }
/* ── Top row: Description + Details ───────────────────────────────── */
.item-detail .top-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 20px;
margin-bottom: 20px;
align-items: stretch;
}
@media (max-width: 900px) {
.item-detail .top-row { grid-template-columns: 1fr; }
}
.item-detail .panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
padding: 22px 26px;
}
.item-detail .panel h2 {
font-size: 15px; font-weight: var(--font-semibold);
margin: 0 0 14px;
text-transform: uppercase; letter-spacing: 0.6px;
color: var(--text-secondary);
}
.item-detail .panel .lead {
font-size: 14.5px; line-height: 1.65; color: var(--text-primary);
white-space: pre-wrap;
}
/* Hero tagline — single-line friendly subtitle below the h1. Same
placement contract as the plugin-detail hero tagline. */
.item-detail .hero .hero-tagline {
font-size: 14.5px; line-height: 1.5;
color: rgba(255,255,255,0.92);
margin: -2px 0 8px;
}
/* Rich-content rendering helpers (parity with marketplace_plugin_detail).
`.lead-rendered` switches the lead block from plain-text mode (which
uses `white-space: pre-wrap` so plain frontmatter descriptions retain
line breaks) to HTML mode where paragraph breaks come from <p> tags. */
.item-detail .lead-rendered { white-space: normal; }
.item-detail .lead-rendered > *:first-child { margin-top: 0; }
.item-detail .lead-rendered > *:last-child { margin-bottom: 0; }
.item-detail .lead-rendered p { margin: 0 0 12px; }
.item-detail .lead-rendered h2,
.item-detail .lead-rendered h3,
.item-detail .lead-rendered h4 {
font-size: 14.5px; font-weight: 600;
margin: 14px 0 6px;
text-transform: none; letter-spacing: 0; color: var(--text-primary);
}
.item-detail .lead-rendered ul,
.item-detail .lead-rendered ol { margin: 0 0 12px 22px; padding: 0; }
.item-detail .lead-rendered code {
background: var(--surface-alt, #f4f4f5); border-radius: 4px;
padding: 1px 5px; font-size: 0.92em;
font-family: var(--font-mono);
}
.item-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;
}
.item-detail .lead-rendered pre code {
background: transparent; border: none; border-radius: 0; padding: 0; color: inherit;
}
.item-detail .lead-rendered a { color: var(--primary); text-decoration: none; }
.item-detail .lead-rendered a:hover { text-decoration: underline; }
/* Spacing between the new rich-content panels — same vertical rhythm
as the existing .section blocks below. */
.item-detail .section-block { margin-top: 24px; }
/* Use-cases grid — 3-column on wide, 2 on tablet, 1 on phone. */
.item-detail .use-cases-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
@media (max-width: 1100px) {
.item-detail .use-cases-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 700px) {
.item-detail .use-cases-grid { grid-template-columns: 1fr; }
}
.item-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;
}
.item-detail .use-case-card h3 {
margin: 0; font-size: 13.5px; font-weight: 600;
color: var(--text-primary); letter-spacing: 0;
text-transform: none;
}
.item-detail .use-case-card p {
margin: 0; font-size: 12.5px; line-height: 1.5;
color: var(--text-secondary);
}
.item-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;
}
.item-detail .use-case-prompt code {
background: transparent;
border: none;
border-radius: 0;
padding: 0;
color: inherit;
}
/* Sample interaction — Claude Code Catppuccin Mocha dark transcript.
Identical visual treatment as plugin-detail's sample block: one dark
panel, monospace user row with green `>` prompt, sans-serif assistant
body with markdown formatting. */
.item-detail .sample-interaction {
background: #1e1e2e;
border: 1px solid rgba(255,255,255,0.06);
border-radius: 10px;
overflow: hidden;
display: flex; flex-direction: column;
}
.item-detail .sample-user,
.item-detail .sample-assistant { padding: 14px 18px; }
.item-detail .sample-user {
border-bottom: 1px solid rgba(255,255,255,0.08);
background: rgba(255,255,255,0.015);
}
.item-detail .sample-label {
/* visually-hidden — `>` prefix is the speaker indicator */
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden; clip: rect(0,0,0,0);
white-space: nowrap; border: 0;
}
.item-detail .sample-user > div:last-child {
font-family: var(--font-mono);
color: #cdd6f4;
font-size: 13.5px; line-height: 1.55;
white-space: pre-wrap; word-break: break-word;
}
.item-detail .sample-user > div:last-child::before {
content: "> ";
color: #a6e3a1; font-weight: 700;
user-select: none;
}
.item-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;
}
.item-detail .sample-assistant-body > *:first-child { margin-top: 0; }
.item-detail .sample-assistant-body > *:last-child { margin-bottom: 0; }
.item-detail .sample-assistant-body p { margin: 0 0 10px; }
.item-detail .sample-assistant-body strong { color: #fab387; }
.item-detail .sample-assistant-body em { color: #f9e2af; font-style: italic; }
.item-detail .sample-assistant-body code {
background: rgba(255,255,255,0.06);
border: none;
color: #f5c2e7;
border-radius: 4px; padding: 1px 5px;
font-size: 0.92em; font-family: var(--font-mono);
}
.item-detail .sample-assistant-body pre {
background: #181825;
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;
}
.item-detail .sample-assistant-body pre code {
background: transparent; border: none; border-radius: 0; padding: 0; color: inherit;
}
.item-detail .sample-assistant-body ul,
.item-detail .sample-assistant-body ol { margin: 0 0 10px 22px; padding: 0; }
.item-detail .sample-assistant-body li { margin: 2px 0; }
.item-detail .sample-assistant-body a {
color: #89b4fa; text-decoration: none;
}
.item-detail .sample-assistant-body a:hover { text-decoration: underline; }
.item-detail .sample-assistant-body blockquote {
border-left: 3px solid #585b70;
margin: 8px 0; padding: 4px 0 4px 12px;
color: #bac2de;
}
.item-detail .sample-assistant-body h2,
.item-detail .sample-assistant-body h3,
.item-detail .sample-assistant-body h4 {
color: #cdd6f4;
font-size: 14px; font-weight: 600;
margin: 12px 0 6px;
text-transform: none; letter-spacing: 0;
}
.item-detail .details dl { margin: 0; }
.item-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;
}
.item-detail .details .row:last-child { border-bottom: none; }
.item-detail .details dt {
color: var(--text-secondary); margin: 0;
font-weight: var(--font-medium);
}
.item-detail .details dd {
margin: 0; color: var(--text-primary); font-weight: var(--font-medium);
text-align: right; word-break: break-word;
}
.item-detail .details dd.mono { font-family: var(--font-mono); font-size: 12px; }
.item-detail .details dd .todo { color: var(--warn-color); font-style: italic; font-weight: 400; }
/* ── Section card (Docs / Files) ─────────────────────────────────── */
.item-detail .section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
margin-bottom: 18px; overflow: hidden;
}
.item-detail .section-head {
display: flex; align-items: center; gap: 10px;
padding: 18px 24px 0;
}
.item-detail .section-head h2 {
margin: 0; font-size: 15px; font-weight: var(--font-semibold);
color: var(--text-primary);
}
.item-detail .section-head .count {
font-size: 12px; color: var(--text-secondary);
background: var(--border-light);
padding: 2px 8px; border-radius: 999px;
}
.item-detail .section-body { padding: 12px 24px 22px; }
.item-detail .file-list { font-family: var(--font-mono); font-size: 12.5px; }
.item-detail .file-list .file {
display: flex; justify-content: space-between; gap: 16px;
padding: 8px 0; border-bottom: 1px dashed var(--border-light);
color: var(--text-primary);
text-decoration: none;
}
.item-detail .file-list .file:last-child { border-bottom: none; }
.item-detail .file-list a.file:hover .name { color: var(--kind); text-decoration: underline; }
.item-detail .file-list .file .name {
display: flex; align-items: center; gap: 8px; min-width: 0;
}
.item-detail .file-list .file .name .icon {
width: 14px; height: 14px; flex-shrink: 0; color: var(--text-secondary);
}
.item-detail .file-list a.file .name .icon { color: var(--kind); }
.item-detail .file-list .file .size {
color: var(--text-secondary); flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.item-detail .empty-msg {
color: var(--text-secondary); font-size: 13px; font-style: italic;
text-align: center; padding: 32px 16px;
}
</style>
<div class="item-detail page-shell" id="root"
data-source="{{ source }}"
data-kind="{{ kind }}"
data-marketplace-id="{{ marketplace_id or '' }}"
data-plugin-name="{{ plugin_name or '' }}"
data-entity-id="{{ entity_id or '' }}"
data-inner-name="{{ inner_name or '' }}"
data-visibility="{{ entity.visibility_status if entity else 'approved' }}">
{# v32+ quarantine banner. Owner / admin only when non-approved. #}
{% include "_quarantine_banner.html" %}
{% if entity and (is_owner or is_admin) %}
<style>
.item-detail .owner-actions {
display: flex; gap: 10px; margin: 0 0 16px 0; justify-content: flex-end;
}
.item-detail .owner-actions a,
.item-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;
}
.item-detail .owner-actions .delete { color: #b91c1c; }
.item-detail .owner-actions button:disabled,
.item-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">
{% 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 %}
{# Same v35 delete UX as the plugin detail page — see comment there. #}
{% if is_admin %}
{% 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.">
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 + removes existing installs. Use only for legal / privacy removals. Visible only to admins — analysts won't see this button.">
Hard delete <span class="admin-only-hint">admin-only</span>
</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.">
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. Edit + re-upload to fix.">
Delete (locked — quarantined)
</button>
{% endif %}
</div>
{% include "_flea_versions.html" %}
<script>
(function(){
const root = document.getElementById('root');
if (root.dataset.source !== 'flea' || !root.dataset.entityId) return;
function bindDel(id, opts){
const b = document.getElementById(id);
if (!b) return;
b.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';
});
}
bindDel('owner-archive-btn', {hard: false, confirm: 'Archive this entity? Disappears from browse; existing installs keep working.'});
bindDel('owner-hard-delete-btn', {hard: true, confirm: 'HARD DELETE — drops bundle + removes ALL existing installs. Continue?'});
})();
</script>
{% endif %}
<!-- Hero (full kind-coloured gradient, parity with plugin detail) -->
<div class="hero">
<div class="crumbs" id="crumbs">
<a href="/marketplace">Marketplace</a>
<span class="sep"></span>
<span id="crumb-loading">Loading…</span>
</div>
<div class="head">
<div class="hero-window" id="hero-photo" aria-hidden="true">
<div class="hero-window-titlebar">
<span class="hwdot red"></span>
<span class="hwdot yellow"></span>
<span class="hwdot green"></span>
<span class="hero-window-label" id="hero-window-label">{{ (entity.title if entity else None) or inner_name or item_name or plugin_name }}</span>
</div>
<div class="hero-window-body" id="hero-window-body">{{ 'SK' if kind == 'skill' else 'AG' }}</div>
</div>
<div class="meta">
<h1 id="hero-name">{{ (entity.title if entity else None) or inner_name or item_name or plugin_name }}</h1>
<div class="hero-tagline" id="hero-tagline" hidden></div>
<div class="meta-row" id="hero-meta-row"></div>
<div class="pills" id="hero-pills">
<span class="pill type">{{ kind }}</span>
<span class="pill {{ source }}">{{ 'Curated' if source == 'curated' else 'Flea' }}</span>
</div>
<!-- Telemetry funnel chip — same four segments as the plugin
detail page (active · calls · trend · Plugin: N installed).
The installed count is the *parent plugin's* stack count
with a "Plugin:" prefix because skills/agents don't have a
per-item subscription model; tooltip spells it out. JS
hides the slot when the parent isn't installed AND the
item has no 30d activity. -->
<div class="hero-telemetry" id="hero-telemetry" hidden></div>
</div>
</div>
<!-- Actions absolutely anchored at the hero's top-right corner.
Action-row holds the install/remove buttons (JS writes into
#hero-actions); stack-hint stacks below as a glass card. -->
<div class="actions">
<div class="actions-row" id="hero-actions"></div>
<div class="helper" id="hero-helper" hidden></div>
<div class="stack-hint" id="stack-hint" hidden>
<div class="head">
<span class="title" id="stack-hint-title">✓ Added to your stack</span>
<button class="dismiss" id="stack-hint-dismiss" type="button">Dont show again</button>
</div>
<div>Run in Claude Code:</div>
<div class="cmd-chip">
<span class="prompt">/</span>
<span class="cmd">update-agnes-plugins</span>
<button class="btn-copy" id="stack-hint-copy" type="button">Copy</button>
</div>
</div>
</div>
</div>
<div class="top-row">
<div class="panel">
<h2>Description</h2>
<div class="lead" id="description-body">Loading…</div>
<div class="invocation-block" id="invocation-block" hidden>
<h3 class="invocation-label">How to call it</h3>
<div class="invocation" id="invocation" title="Run in Claude Code">
<span class="prompt">/</span>
<span class="cmd" id="invocation-cmd"></span>
<button class="btn-copy" id="invocation-copy" type="button">Copy</button>
</div>
</div>
</div>
<aside class="panel details">
<h2>Details</h2>
<dl id="details-list"></dl>
</aside>
</div>
<!-- Use cases — populated from marketplace-metadata.json use_cases[].
Hidden until the curator has supplied at least one card. -->
<div class="panel section-block" 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 — Claude Code-style dark transcript panel. -->
<div class="panel section-block" 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>
<!-- When-to-use disambiguation: rendered markdown ("Use X for Y, see Z
for the alternative…"). Hidden until populated. -->
<div class="panel section-block" id="panel-when-to-use" hidden>
<h2>When to use this</h2>
<div class="lead" id="when-to-use-body"></div>
</div>
<div class="section" id="docs-section" hidden>
<div class="section-head">
<h2>Documentation</h2>
<span class="count" id="docs-count">0</span>
</div>
<div class="section-body">
{# v32: plain link list matching the plugin detail page. No file
icons, no kind chips — every entry is downloadable PDF / Markdown /
plain text by contract (sync drops anything else). #}
<ul class="doc-link-list" id="docs-list" style="list-style:none;padding:0;margin:0;display:grid;gap:8px;"></ul>
</div>
</div>
<div class="section" id="files-section" hidden>
<div class="section-head">
<h2>Files</h2>
<span class="count" id="files-count">0</span>
</div>
<div class="section-body">
<div class="file-list" id="files-list"></div>
</div>
</div>
<div id="error-msg" class="empty-msg" hidden></div>
</div>
<script>
'use strict';
(async function () {
{% include "_marketplace_video_embed.html" %}
const root = document.getElementById('root');
const source = root.dataset.source; // 'curated' | 'flea'
const kind = root.dataset.kind; // 'skill' | 'agent'
const marketplaceId = root.dataset.marketplaceId;
const pluginName = root.dataset.pluginName;
const entityId = root.dataset.entityId;
const innerName = root.dataset.innerName;
// Curated nested → /api/marketplace/curated/{mp}/{plugin}/{kind}/{name}
// Flea inner → /api/marketplace/flea/{id}/{kind}/{name} (skill/agent inside a flea plugin)
// Flea standalone → /api/marketplace/flea/{id}/detail
const apiURL = source === 'curated'
? `/api/marketplace/curated/${encodeURIComponent(marketplaceId)}/${encodeURIComponent(pluginName)}/${kind}/${encodeURIComponent(innerName)}`
: (innerName
? `/api/marketplace/flea/${encodeURIComponent(entityId)}/${kind}/${encodeURIComponent(innerName)}`
: `/api/marketplace/flea/${encodeURIComponent(entityId)}/detail`);
const ICON_DOC = '<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>';
const ICON_DIR = '<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
const ICON_CODE = '<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>';
const CODE_EXTS = new Set(['.py', '.js', '.ts', '.sh', '.bash', '.zsh', '.rb', '.go', '.rs', '.java', '.c', '.cpp', '.h', '.hpp']);
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, ch => (
{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]));
}
function fmtBytes(n) {
if (n == null) return '—';
if (n < 1024) return n + ' B';
if (n < 1024*1024) return (n/1024).toFixed(2) + ' KB';
if (n < 1024*1024*1024) return (n/(1024*1024)).toFixed(2) + ' MB';
return (n/(1024*1024*1024)).toFixed(2) + ' GB';
}
// Short integer formatter — matches the listing card + plugin detail
// so figures look identical across the three surfaces.
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 iconFor(path) {
const lower = path.toLowerCase();
if (lower.endsWith('/')) return ICON_DIR;
const dot = lower.lastIndexOf('.');
const ext = dot >= 0 ? lower.slice(dot) : '';
if (CODE_EXTS.has(ext)) return ICON_CODE;
return ICON_DOC;
}
function fileRow(path, size, opts) {
const tag = opts && opts.href ? 'a' : 'div';
const href = opts && opts.href ? ` href="${esc(opts.href)}" target="_blank" rel="noopener"` : '';
const sizeStr = size == null ? '—' : fmtBytes(size);
return `<${tag} class="file"${href}><span class="name">${iconFor(path)}${esc(path)}</span><span class="size">${sizeStr}</span></${tag}>`;
}
function showError(status) {
document.getElementById('description-body').textContent = '';
const err = document.getElementById('error-msg');
if (status === 403) err.textContent = 'You do not have access to this plugin. Ask your admin to grant your group access.';
else if (status === 404) err.textContent = 'Not found.';
else err.textContent = 'Failed to load (' + status + ').';
err.hidden = false;
}
let res;
try { res = await fetch(apiURL); }
catch { showError(0); return; }
if (!res.ok) { showError(res.status); return; }
const d = await res.json();
// ── Title resolution per source ─────────────────────────────────────
// Curated: marketplace-metadata.json `display_name` wins, else frontmatter `name`.
// Flea standalone skill/agent reuses PluginDetailResponse — `display_name`
// populated by the same on-demand parser path, otherwise `plugin_name`
// (the entity name; manifest_name is the suffixed `<name>-by-<username>`).
const heroTitle = d.display_name
|| (source === 'curated' ? (d.name || innerName) : (d.plugin_name || ''));
document.title = `${heroTitle} — Marketplace`;
// ── Breadcrumbs ────────────────────────────────────────────────────
// Mirror plugin detail shape: Marketplace <Curated Marketplace /
// Flea Market> <parent plugin display name> <self>. The second
// segment is a generic clickable category label (links to the relevant
// marketplace tab), not the per-instance marketplace_name — analysts
// were finding the per-instance name opaque. Parent plugin segment
// (curated only) links to the plugin detail page so users can climb
// one level. Flea items have no parent plugin, so their path is
// Marketplace Flea Market <self>.
const crumbs = document.getElementById('crumbs');
const parentPluginLabel = d.parent_display_name || d.manifest_name || pluginName;
if (source === 'curated') {
crumbs.innerHTML =
`<a href="/marketplace?tab=curated">Marketplace</a>
<span class="sep"></span>
<a href="/marketplace?tab=curated">Curated Marketplace</a>
<span class="sep"></span>
<a href="/marketplace/curated/${esc(marketplaceId)}/${esc(pluginName)}">${esc(parentPluginLabel)}</a>
<span class="sep"></span>
<span class="current">${esc(heroTitle)}</span>`;
} else if (innerName) {
// Flea inner skill/agent — 4-segment path mirroring curated inner:
// Marketplace > Flea Market > <plugin display name> > <self>
crumbs.innerHTML =
`<a href="/marketplace?tab=flea">Marketplace</a>
<span class="sep"></span>
<a href="/marketplace?tab=flea">Flea Market</a>
<span class="sep"></span>
<a href="/marketplace/flea/${esc(entityId)}">${esc(d.parent_display_name || d.manifest_name || pluginName)}</a>
<span class="sep"></span>
<span class="current">${esc(heroTitle)}</span>`;
} else {
// v49 phase-2: drop `d.manifest_name` fallback. manifest_name is the
// suffixed slug (e.g. `xlsx-by-c-marustamyan`) and users explicitly
// didn't want to see it in breadcrumbs. heroTitle (resolved from
// `display_name` which now mirrors `entity.title`) is the
// user-friendly label.
crumbs.innerHTML =
`<a href="/marketplace?tab=flea">Marketplace</a>
<span class="sep"></span>
<a href="/marketplace?tab=flea">Flea Market</a>
<span class="sep"></span>
<span class="current">${esc(heroTitle)}</span>`;
}
// ── Hero name ──────────────────────────────────────────────────────
document.getElementById('hero-name').textContent = heroTitle;
// Hero window — macOS-style frame around the cover. The titlebar label
// carries the skill/agent name (heroTitle); the body holds the cover
// photo (or a placeholder gradient + 'SK'/'AG' initials when no cover).
const labelEl = document.getElementById('hero-window-label');
if (labelEl) labelEl.textContent = heroTitle;
// Cover photo — flea may have one; curated v32 may also have one when
// marketplace-metadata.json sub-tree references a skill/agent cover via
// either an internal path (resolved to /asset/) or an external URL.
if (d.cover_photo_url) {
const bodyEl = document.getElementById('hero-window-body');
// Initials fallback already lives inside #hero-window-body (rendered by
// the server-side template as 'SK' / 'AG'). Capture before swapping so
// a 404 on the cover restores the original glyph.
const initials = bodyEl.textContent || (kind === 'skill' ? 'SK' : 'AG');
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)}">`;
}
// Pills — same structure / class names as plugin detail hero so the two
// surfaces share `.pill.cat / .curated / .flea / .muted` CSS. Item-only
// addition: `.pill.type` (Skill / Agent uppercase, no parallel on plugin
// detail which has no kind axis).
const pills = document.getElementById('hero-pills');
const sourceBadge = source === 'curated'
? '<span class="pill curated">Curated</span>'
: '<span class="pill flea">Flea</span>';
const cat = d.category ? `<span class="pill cat">${esc(d.category)}</span>` : '';
const updatedAt = source === 'curated' ? d.parent_updated_at : d.updated_at;
const updated = updatedAt
? `<span class="pill muted">Updated ${esc(fmtRelative(updatedAt))}</span>`
: '';
pills.innerHTML = `<span class="pill type">${esc(kind)}</span>${sourceBadge}${cat}${updated}`;
// Invocation block — lives inside the Description panel so the "how to
// call it" cue sits right under "what it does". Flea entities ship as
// their own plugin (or the flea bundle), so the manifest_name IS
// the slash invocation. Curated skills/agents live inside a parent
// plugin, so Claude Code namespaces them as /<plugin>:<inner-name>.
const invBlock = document.getElementById('invocation-block');
// Invocation resolution:
// 1. marketplace-metadata.json `invocation` (curator-provided literal —
// may include args like "/my-plugin:tool <your question>",
// and may start with @ for agent invocations). Used verbatim.
// 2. Computed `<manifest_name>:<inner_name>` (legacy behaviour) —
// prefixed with "/" at display + copy time.
let invokeText = null; // what the chip displays (after the "/" prompt)
let invokeCopyText = null; // what gets put in the clipboard on Copy
if (d.invocation) {
// Curator override. Hide the hardcoded "/" prompt so the curator's
// string is shown literally (covering both / and @ forms cleanly).
const promptEl = document.querySelector('#invocation .prompt');
if (promptEl) promptEl.hidden = true;
invokeText = d.invocation;
invokeCopyText = d.invocation;
} else if (source === 'flea' && d.manifest_name) {
invokeText = d.manifest_name;
invokeCopyText = '/' + d.manifest_name;
} else if (source === 'curated' && d.manifest_name && (d.name || innerName)) {
invokeText = `${d.manifest_name}:${d.name || innerName}`;
invokeCopyText = '/' + invokeText;
}
if (invokeText) {
document.getElementById('invocation-cmd').textContent = invokeText;
invBlock.hidden = false;
const copyBtn = document.getElementById('invocation-copy');
copyBtn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(invokeCopyText);
const orig = copyBtn.textContent;
copyBtn.classList.add('copied');
copyBtn.textContent = 'Copied';
setTimeout(() => {
copyBtn.textContent = orig;
copyBtn.classList.remove('copied');
}, 1500);
} catch {
/* clipboard blocked — leave the chip selectable for manual copy */
}
});
}
// Meta-row.
// * Curated inner (always nested under a parent plugin) → "part of …"
// * Flea inner (skill/agent nested in a flea plugin) → same layout,
// parent link points at the flea plugin detail.
// * Flea standalone (entity is itself the skill/agent) → hidden.
// v49 phase-3: the prior "by <author> · N installed · size" line
// duplicated three values that already live on dedicated surfaces
// — install count in the hero telemetry chip below, owner +
// bundle size in the Details sidebar. Empty meta-row would still
// consume vertical rhythm via .meta-row's margin, so we hide it.
const metaRow = document.getElementById('hero-meta-row');
if (source === 'curated' || innerName) {
const author = (d.parent_author_name && d.parent_author_name !== 'owner_todo')
? `<strong>${esc(d.parent_author_name)}</strong>`
: `<strong style="font-style:italic; color:var(--warn-color);">owner_todo</strong>`;
const parentHref = source === 'curated'
? `/marketplace/curated/${esc(marketplaceId)}/${esc(pluginName)}`
: `/marketplace/flea/${esc(entityId)}`;
const parentLabel = d.parent_display_name || d.manifest_name || pluginName;
metaRow.innerHTML =
`<span>part of <a href="${parentHref}"><strong>${esc(parentLabel)}</strong></a></span>
<span class="dot">·</span>
<span>by ${author}</span>`;
metaRow.hidden = false;
} else {
metaRow.innerHTML = '';
metaRow.hidden = true;
}
// Hero action — Curated nested redirects to parent plugin (no install at
// this level — Q5: install via the plugin). Flea standalone is directly
// installable through the existing /api/store/.../install endpoint.
const actions = document.getElementById('hero-actions');
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;
}
function setHintTitle(kind) {
const t = document.getElementById('stack-hint-title');
if (!t) return;
t.textContent = kind === 'removed'
? '✓ Removed from your stack'
: '✓ Added to your stack';
}
function renderInstallActionsHtml(installed) {
if (installed) {
return `
<span class="status-pill" id="status-pill">✓ In your stack</span>
<button class="btn-remove" id="remove-btn" type="button">✕ Remove from stack</button>
`;
}
return `<button class="btn primary" id="install-btn" type="button">+ Add to my stack</button>`;
}
// Wires whichever of {install-btn, remove-btn} is currently in the
// actions area. Both handlers re-render + re-bind so a returning
// user can flip back and forth without a page reload. The hint
// recipe is identical on add and remove (Claude Code needs the same
// /update-agnes-plugins refresh either way) — only the title swaps.
function bindInstallActions() {
const installURL = `/api/store/entities/${encodeURIComponent(entityId)}/install`;
const installBtnEl = document.getElementById('install-btn');
if (installBtnEl) {
installBtnEl.addEventListener('click', async () => {
const r = await fetch(installURL, { method: 'POST' });
if (!r.ok) { alert('Add failed (' + r.status + ')'); return; }
actions.innerHTML = renderInstallActionsHtml(true);
bindInstallActions();
setHintTitle('added');
showHint();
});
}
const removeBtnEl = document.getElementById('remove-btn');
if (removeBtnEl) {
removeBtnEl.addEventListener('click', async () => {
const r = await fetch(installURL, { method: 'DELETE' });
if (!r.ok) { alert('Remove failed (' + r.status + ')'); return; }
actions.innerHTML = renderInstallActionsHtml(false);
bindInstallActions();
setHintTitle('removed');
showHint();
});
}
}
// Inner skills/agents (curated nested OR flea nested) can't be added to
// a user's stack on their own — they live inside a parent plugin bundle
// and adoption only happens at the plugin level. Render "Open parent
// plugin →" + helper text instead of the install/remove buttons. Flea
// standalone entities (else branch below) keep the normal install UX.
if (source === 'curated' || innerName) {
const parentHref = source === 'curated'
? `/marketplace/curated/${esc(marketplaceId)}/${esc(pluginName)}`
: `/marketplace/flea/${esc(entityId)}`;
const parentLabel = d.parent_display_name || d.manifest_name || pluginName;
// Button alone in the actions-row (the row is flex-direction: row;
// putting the helper inside it would lay them side-by-side). The
// helper sits in #hero-helper, a sibling under the actions-row, so
// the outer .actions flex-column stacks them: button → helper.
actions.innerHTML = `<a class="btn primary" href="${parentHref}">Open parent plugin →</a>`;
const helperEl = document.getElementById('hero-helper');
helperEl.innerHTML = `
This ${esc(kind === 'skill' ? 'skill' : 'agent')} is part of <strong>${esc(parentLabel)}</strong>.<br>
Add the bundle to your stack to use it.`;
helperEl.hidden = false;
} else {
// v32+ quarantine: when the entity is non-approved (only owner +
// admin land here — server-side gate 404s anyone else), render
// the install button gray + disabled with explanatory tooltip.
// The API also refuses POST /install with 409 entity_not_approved
// so a clever user toggling `disabled` in devtools still hits the
// gate. Skip listener wiring below for inert buttons.
const isQuarantined = d.visibility_status && d.visibility_status !== 'approved';
if (isQuarantined) {
const stateLabel = d.visibility_status === 'archived' ? 'archived' : 'under review';
actions.innerHTML = `<button class="btn primary" type="button" disabled
title="This submission is not approved yet — install is disabled until checks pass."
style="background:#e5e7eb;color:#6b7280;cursor:not-allowed;border:1px solid #d1d5db;">
+ Add to my stack (unavailable while ${stateLabel})
</button>`;
} else {
// Two-element installed state — inline status label BEFORE the
// Remove button on the same row. Default state is the primary
// install CTA. Re-rendered after every click since the DOM
// elements differ between states; bindInstallActions() re-wires
// listeners on whichever id is now present.
actions.innerHTML = renderInstallActionsHtml(!!d.installed);
}
const dismissBtn = document.getElementById('stack-hint-dismiss');
if (dismissBtn) {
dismissBtn.addEventListener('click', () => {
localStorage.setItem(HINT_DISMISS_KEY, '1');
hintEl.hidden = true;
});
}
const copyBtn = document.getElementById('stack-hint-copy');
if (copyBtn) {
copyBtn.addEventListener('click', async (ev) => {
const b = ev.currentTarget;
try {
await navigator.clipboard.writeText('/update-agnes-plugins');
const orig = b.textContent;
b.classList.add('copied');
b.textContent = 'Copied';
setTimeout(() => { b.textContent = orig; b.classList.remove('copied'); }, 1500);
} catch { /* clipboard blocked — chip text remains selectable */ }
});
}
// install-btn / remove-btn ids only exist in the non-quarantined
// branch above. bindInstallActions wires whichever is currently
// rendered; the click handlers re-render the actions area + re-bind
// so the next click hits the freshly-attached listener.
bindInstallActions();
}
// ── Hero tagline (marketplace-metadata.json :: tagline) ────────────
// Optional 1-line value prop sitting under the h1. Stays hidden when
// the curator hasn't filled it; the meta-row below still shows the
// "by author · updated date" line so the hero never collapses.
const heroTaglineEl = document.getElementById('hero-tagline');
if (heroTaglineEl && d.tagline) {
heroTaglineEl.textContent = d.tagline;
heroTaglineEl.hidden = false;
}
// ── Hero telemetry chip ───────────────────────────────────────────
// Four-segment funnel matching the plugin detail page, with one
// twist: the installed segment shows the *parent plugin's* stack
// count under a "Plugin:" label, because skills/agents inherit
// adoption from their plugin (no per-item subscription model). The
// tooltip spells out the relationship. Hidden when neither the
// parent is installed AND the item has no 30d activity.
(function renderInnerHeroTelemetry() {
const slot = document.getElementById('hero-telemetry');
if (!slot) return;
const tel = d.telemetry || {};
// Adoption source per scenario:
// * Curated inner — `parent_stack_count` (parent plugin's subscribers).
// * Flea inner — `parent_stack_count` (backend fills it with the
// parent flea plugin's install_count — see
// `flea_skill_detail` / `flea_agent_detail`).
// * Flea standalone (no innerName) — `install_count` directly on the
// entity.
const installedCount = (source === 'curated' || innerName)
? (d.parent_stack_count || 0)
: (d.install_count || 0);
const activeUsers = tel.distinct_users_30d || 0;
const calls = tel.invocations_30d || 0;
const trend = tel.trend_pct;
if (installedCount === 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");
const segs = [
`<span class="seg-active" title="${activeUsers} users invoked this ${esc(kind)} in the last 30 days">${ICON_USER} ${fmtNum(activeUsers)} active</span>`,
`<span class="seg-calls" title="${calls} invocations of this ${esc(kind)} 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 of this ${esc(kind)}">${icon} ${Math.abs(Math.round(trend))}%</span>`);
}
// Installed segment differs per scenario:
// * Curated inner / flea inner: parent plugin's stack count —
// "Plugin:" prefix + tooltip make the semantic shift explicit.
// We're not showing how many people use this skill; we're
// showing how many have the parent plugin in their stack. The
// funnel insight: "12 installed plugin → 2 actually use this skill".
// * Flea standalone (no innerName): entity-level install_count.
// Plain `N installed` matches the plugin detail flea hero.
if (source === 'curated' || innerName) {
const parentPluginLabel = esc(d.parent_display_name || d.manifest_name || pluginName || 'parent plugin');
segs.push(`<span class="seg-installed" title="Parent plugin (${parentPluginLabel}) currently installed by ${installedCount} users">${ICON_STACK} <span class="seg-installed-label">Plugin:</span> ${fmtNum(installedCount)} installed</span>`);
} else {
segs.push(`<span class="seg-installed" title="Installed by ${installedCount} users">${ICON_STACK} ${fmtNum(installedCount)} installed</span>`);
}
slot.innerHTML = segs.join(' · ');
slot.hidden = false;
})();
// ── Description ────────────────────────────────────────────────────
// When the curator authored a rich markdown body in marketplace-metadata.json,
// the API renders + sanitizes it server-side and ships as
// `description_long_html`. We inject as HTML. Falling back to the
// 1-line frontmatter `description` preserves the historical render path
// for skills/agents whose curator hasn't filled the new field yet.
const descEl = document.getElementById('description-body');
if (d.description_long_html && d.description_long_html.trim()) {
descEl.innerHTML = d.description_long_html;
descEl.classList.add('lead-rendered');
} else {
descEl.textContent = d.description || '';
}
// ── Use cases (marketplace-metadata.json :: use_cases[]) ───────────
// One card per entry. Hidden until populated.
const useCasesPanel = document.getElementById('panel-use-cases');
const useCasesGrid = document.getElementById('panel-use-cases-grid');
if (useCasesPanel && useCasesGrid && Array.isArray(d.use_cases) && d.use_cases.length) {
useCasesGrid.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;
}
// ── Sample interaction (marketplace-metadata.json :: sample_interaction) ─
// Claude Code-style dark transcript. Assistant body was rendered +
// sanitized server-side; we inject as HTML.
const samplePanel = document.getElementById('panel-sample');
if (samplePanel && 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;
samplePanel.hidden = false;
}
// ── When to use this (marketplace-metadata.json :: when_to_use) ────
// Markdown disambiguation against alternative skills/agents
// ("Use this for X; for Y, see /other-skill"). Rendered server-side.
const whenPanel = document.getElementById('panel-when-to-use');
const whenBody = document.getElementById('when-to-use-body');
if (whenPanel && whenBody && d.when_to_use_html && d.when_to_use_html.trim()) {
whenBody.innerHTML = d.when_to_use_html;
whenBody.classList.add('lead-rendered');
whenPanel.hidden = false;
}
// ── Details sidebar — skip rows whose value is missing. The
// `owner_todo` placeholder for the Curator row stays as a deliberate
// reminder to wire up curator metadata.
//
// v49 phase-7: unified scan order with plugin detail surface.
// Priority: identity (who) → life-stage (when) → telemetry (how used)
// → debug-tier (version, size). Inner skills/agents pivot on parent
// plugin, so Parent plugin slots immediately after authorship.
const dl = document.getElementById('details-list');
// Telemetry helper — Last used first (recency, primary decision
// driver), Active days second (engagement consistency over 30d).
// Both rendered only when the item has at least one active day so a
// brand-new skill doesn't show "Last used —" + "0 of 30".
const telemetryRows = (() => {
if (!d.telemetry || !Array.isArray(d.telemetry.daily_series)) return '';
const series = d.telemetry.daily_series;
const activeDays = series.filter(p => (p.invocations || 0) > 0).length;
if (activeDays === 0) return '';
let lastIso = null;
for (let i = series.length - 1; i >= 0; i--) {
if ((series[i].invocations || 0) > 0) { lastIso = series[i].day; break; }
}
return (lastIso
? `<div class="row"><dt>Last used</dt><dd>${esc(fmtRelative(lastIso))}</dd></div>`
: '')
+ `<div class="row"><dt>Active days</dt><dd>${activeDays} of ${series.length}</dd></div>`;
})();
if (source === 'curated' || innerName) {
// Nested skill/agent (curated OR flea-inner). Sidebar render order:
// 1. Curator / Owner — first scan signal (trust)
// 2. Parent plugin — where this lives
// 3. Last used — recency
// 4. Active days — engagement
// 5. Bundle size — debug-tier
const author = (d.parent_author_name && d.parent_author_name !== 'owner_todo')
? esc(d.parent_author_name)
: '<span class="todo">owner_todo</span>';
const authorLabel = source === 'curated' ? 'Curator' : 'Owner';
dl.innerHTML = `
<div class="row"><dt>${authorLabel}</dt><dd>${author}</dd></div>
<div class="row"><dt>Parent plugin</dt><dd>${esc(d.parent_display_name || d.manifest_name || pluginName)}</dd></div>
${telemetryRows}
${d.bundle_size != null ? `<div class="row"><dt>Bundle size</dt><dd>${esc(fmtBytes(d.bundle_size))}</dd></div>` : ''}`;
} else {
// Flea standalone skill/agent. Sidebar render order:
// 1. Owner — first scan signal (trust)
// 2. Released — life-stage
// 3. Last used — recency
// 4. Active days — engagement
// 5. Version — debug-tier
// 6. Bundle size — debug-tier
// Drops the previous Category + Installs rows (duplicated hero
// badge + telemetry chip — see v49 phase-7 notes).
const ownerLabel = d.owner_display || d.author_name || '';
dl.innerHTML = `
${ownerLabel ? `<div class="row"><dt>Owner</dt><dd>${esc(ownerLabel)}</dd></div>` : ''}
${d.released_at ? `<div class="row"><dt>Released</dt><dd>${esc(fmtRelative(d.released_at))}</dd></div>` : ''}
${telemetryRows}
${d.version ? `<div class="row"><dt>Version</dt><dd class="mono">v${esc(d.version)}</dd></div>` : ''}
${d.bundle_size != null ? `<div class="row"><dt>Bundle size</dt><dd>${esc(fmtBytes(d.bundle_size))}</dd></div>` : ''}`;
}
// ── Docs section ────────────────────────────────────────────────
// Flea has always populated `d.docs`. v32 added the same field for curated
// skill/agent inner detail, sourced from marketplace-metadata.json's per-skill
// sub-tree. Both surface here through a single render path — file rows
// for internal-cached docs, link rows for external ones.
if (Array.isArray(d.docs) && d.docs.length) {
// Plain-link rendering — same shape as the plugin detail page. The
// server already filtered out anything that isn't a real downloadable
// PDF / Markdown / plain text, so every entry here is clickable +
// downloads on click (Content-Disposition: attachment from the API).
document.getElementById('docs-section').hidden = false;
document.getElementById('docs-count').textContent = String(d.docs.length);
document.getElementById('docs-list').innerHTML = d.docs.map(doc => `
<li style="padding:10px 12px;border-radius:8px;background:var(--surface-alt,#f9fafb);border:1px solid var(--border);">
<a href="${esc(doc.url || '#')}" download
style="color:var(--primary);text-decoration:none;font-weight:500;display:block;"
onmouseover="this.style.textDecoration='underline'"
onmouseout="this.style.textDecoration='none'">${esc(doc.name)}</a>
</li>`).join('');
}
// ── v32: demo video for skill / agent (from marketplace-metadata sub-tree) ──
// Same auto-embed logic as the parent plugin detail page: YouTube /
// Vimeo are embedded; raw .mp4/.webm/.ogg are inlined as <video>; anything
// else falls back to a "Watch on external site" link.
if (source === 'curated' && d.video_url) {
let videoSection = document.getElementById('video-section');
if (!videoSection) {
videoSection = document.createElement('div');
videoSection.id = 'video-section';
videoSection.className = 'section';
// .video-embed wrapper lives INSIDE .section-body so it inherits
// the same 12px / 24px / 22px padding as the docs / files sections —
// without that, the iframe bleeds to the panel edge.
videoSection.innerHTML = `
<div class="section-head">
<h2>Demo video</h2>
</div>
<div class="section-body">
<div class="video-embed" id="video-wrap"></div>
</div>`;
// Insert above the docs section so video shows first when both exist.
const docs = document.getElementById('docs-section');
docs.parentElement.insertBefore(videoSection, docs);
}
document.getElementById('video-wrap').innerHTML =
buildVideoEmbed(d.video_url, esc);
}
// ── Files section — both sources, when non-empty ────────────────────
if (Array.isArray(d.files) && d.files.length) {
document.getElementById('files-section').hidden = false;
document.getElementById('files-count').textContent = String(d.files.length);
document.getElementById('files-list').innerHTML =
d.files.map(f => fileRow(f.path, f.size, null)).join('');
}
})();
</script>
{% endblock %}