* feat(unified-stack): Browse + My Stack + Recipes + RBAC matrix (v49–v55)
Squash of 94 commits spanning the v49 → v55 unified-stack rewrite.
Full per-feature breakdown lives in CHANGELOG.md under [Unreleased].
Major buckets:
* v49 schema — first-class user_groups + user_group_members +
resource_grants; admin can CRUD groups and grants; Google
Workspace nightly sync writes into the new tables.
* v49 data_packages — admin-curated bundles of tables, RBAC-gated,
first-class section on /catalog Browse + My Stack.
* v49 memory_domains — row-backed (replaces hardcoded VALID_DOMAINS
enum); admin can CRUD; grants follow the same shape as tables and
packages.
* v50 cover_image_url + admin sidebar collapsibles + per-row Mode
tooltip + admin queue domain badges + admin "+ New Item" seed flow.
* v51 lifecycle status (prod/poc/coming-soon/draft) + category +
palette swatches on admin modals.
* v52 per-table detail page /catalog/t/<id>.
* v53 Recipes — admin-curated SQL templates as a second tab on
/catalog with full Edit/Delete admin affordances.
* v54 soft-delete (deleted_at) + Undo toast for packages, memory
domains, and recipes; hard_delete() retained as escape hatch.
* v55 Recipes RBAC — ResourceType.RECIPE registered, inline Group
Access matrix on Create + Edit Recipe modals (mirrors the Memory
Domain pattern).
* Activity Center per-resource filter (resource_prefix LIKE-anchored
on audit_log.resource); admin nav g+letter keyboard shortcuts;
loadAdminTablesLayout N+1 → single endpoint; /api/memory 30s
page-level cache.
* CI hardening — Keboola legacy tests pytest.importorskip; perf-
smoke threshold widened to stop cold-cache flake.
5002 tests passing, 35 skipped.
* feat(p2 backlog): Cmd-K palette + suggest-a-domain + nightly E2E + v55 schema
10-item P2 sweep on top of the unified-stack squash. New behaviour:
* Cmd-K admin command palette (base.html) — fuzzy-search overlay over
admin + user-facing routes. Arrows/Enter to navigate, Esc to close.
* Stack-tabs digit shortcuts — 1/2/3 switch Browse / My Stack /
Recipes on /catalog + /corporate-memory.
* Friendlier non-admin empty state on /corporate-memory, plus a
"Suggest a domain" CTA → POST /api/memory-domain-suggestions, admin
queue with approve/reject. Backed by a new memory_domain_suggestions
table (schema v55).
* /admin/corporate-memory 7-tab strip grouped under Moderation /
Catalog parent labels.
* Bulk-assign table → package dropdown annotates each option with
"(N of M tables already in)" so the existing distribution is visible
before picking a target.
* GET /api/memory + /tree accept is_required filter; admin status
dropdowns route the "Required" sentinel onto it (status no longer
holds 'mandatory' post-v49, so the old dropdown returned nothing).
* chip-input.js is now opt-in per template via {% block extra_scripts %}
instead of loaded globally on every page from base.html.
* Edit-modal close helpers consolidated onto _closeEditModalById();
docs the per-source-type modal architecture decision.
* New .github/workflows/e2e-nightly.yml runs agent-browser smoke
scripts (scripts/e2e/smoke_*.sh) against a docker-compose stack
nightly at 04:30 UTC; failures open an agent-browser-nightly issue.
5012 tests passing, 35 skipped.
* fix(visual audit): 6 page regressions on memory + data-package surfaces
agent-browser walkthrough of every memory + data-package page in the PR
turned up 6 real bugs. Fixes:
1. Admin memory modals were dead. Duplicate `let _cmdNewDomainId`
declarations from the deprecated step-2 RBAC stubs in
admin_corporate_memory.html collided with the live state vars
declared earlier in the same <script> → SyntaxError on parse →
the entire second script block silently failed → every inline
onclick= handler defined there (`+ New Memory Domain`, Edit, etc.)
was a no-op. Removed the duplicate stubs.
2. /catalog/t/<table_id> + /catalog/r/<slug> rendered unstyled.
Both templates injected their CSS via {% block head %} but
base.html exposes {% block head_extra %} — wrong block name
meant <style> rules never reached the rendered HTML. Renamed
to head_extra. Hero card, section cards, dark SQL block, proper
full-width inputs all now render as designed.
3. L49 leak — "MANDATORY" KPI label + "Make Mandatory" row buttons
on /admin/corporate-memory still used the old word. Renamed to
"Required" / "Mark as Required" so UI matches the data model
(v49 split moved the Required tier onto the orthogonal
is_required boolean; status no longer holds 'mandatory').
4. Activity Center Resource dropdown didn't know the v55
`memory_domain_suggestion:` namespace — added it.
5. Tab strip on /admin/corporate-memory wrapped text 2× per button
on narrow viewports after the L50 MODERATION/CATALOG group
labels pushed total width past most viewports. Switched the
strip to flex-wrap:nowrap + overflow-x:auto with
white-space:nowrap + flex-shrink:0 on every direct child so the
tabs stay one row and slide horizontally when they overflow.
5012 tests passing, 35 skipped.
* rebase-cleanup: align with main's 0.54.25-27 API design + comment fix
Three follow-on fixes after rebasing onto origin/main (0.54.27):
* admin_tables.html: dropped a stray nested ``{% if data_source_type
== 'keboola' %}`` around ``prefillFromKeboolaTable`` (main never had
it; the outer Phase F2 guard already covers it) and reworded a JS
comment that contained literal ``{% %}`` tokens which Jinja was
parsing as a real tag → unbalanced if/endif → 30 template render
failures across the suite.
* /api/stack/subscription/{type}/{id}: DELETE now returns 204 instead
of 200 per the 0.54.26 design rules. CLI client + parity tests
updated to accept 2xx / assert 204.
* Memory-domain suggestion approve/reject paths added to
``_VERB_PATH_ALLOWLIST`` — they are pending → approved/rejected
state-machine transitions (approve also creates the real
memory_domains row as a side effect), so the RPC shape is
intentional rather than a missed PATCH refactor.
5035 tests passing, 35 skipped.
* fix(catalog_table_detail): real polish pass — hero glyph, dedup pills, rows/size meta, scoped sync CTA
The previous fix only got the block-name typo so the existing CSS rendered.
The actual layout was still wireframe-tier on close inspection:
* No cover glyph in the hero (a flat white card with title + meta line);
data-package + memory-domain detail pages both have a colored icon
square. Restored parity — table.icon emoji if set, otherwise initials
on a colored square using table.color.
* "INTERNAL" pill rendered twice for agnes_audit etc. — the mode pill
and the source-type pill happened to be identical strings. Now skip
the source pill when it matches the mode (`internal == internal`).
* Bucket / source_table code chip showed `Agnes Internal.audit_log` for
internal rows — meaningless to a user. Hidden when source_type is
internal.
* `pairs_well_with` admin input was a comma-separated `<input>` always
visible. Wrapped all 4 sections in an Edit-on-demand toggle: read-
only display by default, "+ Add" / "Edit" button on the right edge
of each section header reveals the inline form, Cancel hides it.
* "Trigger sync now" was a cramped link squashed into the empty-state
flex row (visible as `Tr…` overflow before). Promoted to a proper
btn-primary button under the empty-state copy. Hidden entirely for
internal tables (which are server-managed — no upstream to pull).
* Hero meta now surfaces row count + payload size (when sync_state has
them) + last sync timestamp on a single line — was missing from the
original.
* Mode pills colored by tier (local=green, remote=amber, materialized=
blue, internal=gray) so the basic fact about a table reads at a
glance, not from upper-cased ALL-CAPS text alone.
* tests(v56): TDD baseline for extended data-packages content + per-table docs
68 failing tests across 8 files spec the v56 surface before any
implementation lands:
* test_schema_v55_to_v56_migration.py — schema bump, additive ALTERs
on data_packages + table_registry, idempotency, sequential-upgrade
preservation
* test_data_packages_repo_v56.py — repo create/update/get/list for
owner_name, owner_team, tags, long_description, when_to_use,
when_not_to_use, example_questions (JSON list round-trip, empty
defaults, partial-update preservation)
* test_table_registry_v56_docs.py — update_docs for grain, platforms,
partition_col, history, gotchas; preserves v52 docs columns
* test_api_data_packages_v56.py — PUT/POST/GET for all new fields,
field-level validation (tag count, bullet length, description size),
virtual badge derivation (curated/new)
* test_api_registry_docs_v56.py — PATCH /api/admin/registry/{id}/docs
for v56 fields, validation, RBAC unchanged
* test_web_catalog_package_detail_v56.py — /catalog/p/<slug> rewrite
asserts on rendered owner line, tag pills, badges, What it is,
Use it when, Skip it when, Example questions, per-table extended
detail in collapsible row, key-gotcha distinctness, admin-only Edit
* test_web_stack_card_v56_metadata.py — Browse-grid card additions
(owner chip, tag chips, badges) without breaking back-compat for
rows missing the new fields
* test_data_packages_no_vendor_content.py — CI guard: scans app/ +
src/ + cli/ + config/ + scripts/ for Groupon-specific tokens from
the colleague's spec MD; fails if any leak into OSS surfaces
* test_db_schema_version.py — bumped 55 → 56 with rationale
Plus updates schema-version assertion to 56. Implementation lands in
subsequent commits (schema migration → repo → API → templates).
* feat(v56): schema + repo for extended data-packages content
Schema additions (ALTER ADD COLUMN IF NOT EXISTS — additive + idempotent):
* data_packages: owner_name, owner_team, tags, long_description,
when_to_use, when_not_to_use, example_questions (JSON-as-VARCHAR for
the lists)
* table_registry: grain, platforms, partition_col, history, gotchas
(extends the v52 sample_questions / things_to_know / pairs_well_with
docs surface with structured per-table content)
Repo extensions:
* DataPackagesRepository.create + update accept the new fields with
the same Optional-is-no-op contract as v51 (pass an empty list to
clear a JSON column)
* _decode_row decodes the new JSON-list columns to Python lists; NULL
rounds back to [] so callers don't branch
* TableRegistryRepository.update_docs grew the v56 fields alongside
the existing v52 ones — single PATCH can write either tier
atomically
* TableRegistryRepository._decode_row picks up platforms + gotchas in
the same NULL-tolerant decoder
22 repo + migration tests passing. API + UI land in subsequent commits.
* feat(v56): API surface for extended data-packages + per-table docs
CreateDataPackageRequest + UpdateDataPackageRequest grew the v56 fields
(owner_name, owner_team, tags, long_description, when_to_use,
when_not_to_use, example_questions) with per-field validators that
match the Foundry spec checklist:
* tags: ≤8 entries × ≤30 chars
* long_description: ≤4000 chars
* use/skip: ≤8 bullets × ≤200 chars
* example_questions: ≤12 × ≤200 chars
_serialize emits all v56 fields plus a virtual ``badges`` list derived
server-side at render time (no DB column needed): "curated" when the
creator is in the Admin group, "new" within 30 days of created_at.
Backdating created_at or admin-status changes pick up automatically.
PATCH /api/admin/registry/{id}/docs extended with v56 structured
per-table fields (grain, platforms, partition_col, history, gotchas).
gotchas: list of {key: bool, body: str} Pydantic models with the same
≤8 cap; first key=true entry becomes the Key gotcha on the rendered
package detail page. PATCH echoes the fresh state so callers can
re-render without a second GET.
26 API tests passing (16 data-packages + 10 registry-docs).
* feat(v56): /catalog/p/<slug> rewrite + Browse-grid card augmentation
The third (and final) v56 commit lights up the UI surfaces backed by
the schema + API commits earlier in this PR:
* /catalog/p/<slug> template rebuilt around the Foundry spec's
section ladder — hero (icon + name + badges + owner + tags +
description + meta + Add-to-stack), "What it is" markdown body,
paired "Use it when / Skip it when" panels, "Tables in this
package" with collapsible per-table extended detail (grain /
platforms / partition_col / history / gotchas + sample questions),
and an "Example questions you can ask Claude" prompt panel. Each
section guarded by ``{% if pkg.<field> %}`` — empty content fields
hide the section entirely (no "No X yet" placeholder noise on the
public-facing drilldown).
* router catalog_package_detail hydrates per-table v56 fields onto
the tables list + derives the virtual badges (curated / new)
server-side from creator-in-Admin + 30-day created_at.
* StackResolver.ResourceEntry grew owner_name / owner_team / tags /
badges; _fetch_entries pulls the v56 columns + computes badges
once per fetch using a single Admin-group SELECT.
* _data_package_entry_dict adapter passes the new fields through to
the macro; tags are merged source-type pills + admin-authored
category tags per the spec convention.
* _stack_card.html renders the v56 badges (top-left, data-badge=
hooks) + the owner chip (data-card-owner hook) without breaking
back-compat — pre-v56 rows render unchanged.
* Admin PUT handler strips the v56 docs fields from the
read-modify-write merged dict so register() doesn't blow up
with the now-larger row shape (same pattern as the v52 docs
fields stripping).
5115 tests passing (+98 v56 + 18 fixed regressions from the merged-
register PUT path), 35 skipped.
* fix(rbac): Edit-on-package + Group-access 'required' persistence + CI vendor guard
Three related bugs reported on the merged-with-main branch:
1. Clicking Edit on a Data Package card landed on /admin/tables with
a `#<pkg.id>` hash that nothing listened to — admin saw the global
table listing, not the editor for that specific package. Added a
`?edit_package=<pkg_id>` query-param handler in admin_tables.html
(analog to the existing `?edit=<table_id>` and `?assign_to=<pkg_id>`
patterns) that calls openEditDataPackageModal on DOMContentLoaded
after a 250ms layout settle. Updated the package-detail Edit link
to use the new query param.
2. Setting Group Access to 'required' didn't persist — re-opening
the modal showed 'available'. Root cause was the v49
``resource_grants.requirement`` enum existing in the DB but the
POST /api/admin/grants endpoint not surfacing it: ``CreateGrantRequest``
declared only group_id + resource_type + resource_id, so Pydantic
silently dropped the matrix's ``requirement: 'required'`` payload
and the new row landed at the DB column default ('available').
Plumbed ``requirement`` through ``CreateGrantRequest`` →
``ResourceGrantsRepository.create`` so the value persists in one
round-trip. Plus a UNIQUE-constraint race in the matrix
diff-apply: DELETE-old + POST-new ran in parallel via
``Promise.allSettled``, so POST could fire first and trip the
unique check before DELETE freed the slot. Switched to sequential
(await all deletes; then await all writes) across all three
matrices (Edit Data Package, Edit Memory Domain, Edit Recipe).
3. CI vendor-content guard ``test_no_groupon_specific_strings_in_oss``
tripped on two of my own docstrings: a "Foundry Data team" mention
in two src/db.py comments + an ``s1_session_landings`` example in
cli/skills/agnes-table-registration.md. Rephrased the comments to
"extended-descriptions admin spec" and replaced the example with
a generic ``events_daily`` table name.
5164 tests passing, 35 skipped (+4 regression tests pinning the POST
/api/admin/grants requirement contract). Vendor guard back to green.
* fix(catalog): admin Browse path drops v58 card fields
The /catalog and /memory admin god-mode branch built ResourceEntry
instances inline from pkg_repo.list() / domains_repo.list() and skipped
owner_name, owner_team, tags, and derived badges (curated/new). Visible
symptom: a package with an owner + tags rendered with the v56 chrome
for non-admin viewers but as a bare card for admins.
Adds StackResolver.browse_admin(user_id, resource_type) — admin god-mode
Browse that walks the full table but routes through the same
_fetch_entries enrichment pass as browse(), so admin + non-admin Browse
stay visually consistent. Both /catalog and /corporate-memory routes
switch to it.
Regression test in tests/test_stack_resolver_browse_admin.py covers:
owner/tags propagation, new/curated badge derivation, in_stack from
admin subscriptions, all-packages-regardless-of-grants, and the
ValueError for unsupported resource types.
* fix(catalog): three /catalog tab-strip UX bugs
1. Required Remove → red toast
browse_admin passed empty required_ids to _fetch_entries, so the
admin's own required grants surfaced as 'available' and the macro
rendered an actionable Remove button that POST /unsubscribe 400'd
on. Now derives required_ids from the admin's own groups so
Required packages render with the disabled "In stack (required)"
button. Regression test in test_stack_resolver_browse_admin.py.
2. Remove green-toasts but card stays until refresh
The My-Stack empty-state placeholder was only emitted server-side
when stack_entries was empty at render time. Removing the last
card left the tab completely blank — users read that as "Remove
didn't work, let me refresh". Both grid + empty-state are now
always rendered with one of them initially hidden; the JS swaps
visibility on add/remove instead of injecting DOM. Same fix in
/corporate-memory.
3. "What are Recipes?" + ambiguous (admin) suffix
Recipes tab now carries its own curator-block explainer (the
shared one was moved inside Browse view so it doesn't bleed
across tabs). The grey "(admin)" suffix becomes a yellow
.admin-only-hint chip with a title tooltip — visibility hint is
now unambiguous: yellow chip = "only you see this", non-admins
don't see the affordance at all.
* schema: renumber v51..v58 → v52..v59 to make room for main's v51
Main 0.54.29 introduced a NEW v51 (table_registry.bq_fqn — issue #343)
that releases ahead of this branch. The unified-stack chain v51..v58
shifts up by one so main's v51 stays as the released schema and ours
become v52..v59. Function names, internal version bumps, dispatch
ladder thresholds, and the migration-test references all move
together. Subsequent merge with main lands the bq_fqn column at the
freed v51 slot.
* fix(seed): seed admin lands in BOTH Admin AND Everyone groups
The LOCAL_DEV_MODE / SEED_ADMIN_EMAIL bootstrap only added the seed
user to Admin. Everyone-scoped grants — the canonical "every-user-
sees-this" pattern for Required onboarding — didn't surface for the
seed admin's own /catalog because they weren't in Everyone. Symptom:
admin grants a Required-tier package to Everyone, then sees it on
/catalog still rendered with an "Add to stack" button (because the
admin's resolved required_ids was empty for that package).
The dual-membership keeps Admin (authorization) and Everyone
(default-grant target) intentionally separate per the design comment
on UserRepository.create — every membership remains traceable to a
concrete row, just now with a system_seed row in Everyone too. Both
INSERTs go through UserGroupMembersRepository.add_member which is
idempotent on (user_id, group_id), so re-fires on every lifespan
startup don't duplicate rows.
Regression test in test_main_seed_admin_everyone.py.
* style: unify admin-only hints across marketplace + memory detail pages
Replaces three stale ``(admin)`` parentheticals with the same yellow
``admin-only`` chip introduced for /catalog tab actions. Same tooltip
copy ("Visible only to admins — analysts won't see this …") so the
visibility hint is unmistakable wherever it appears:
- Hard delete on marketplace_plugin_detail (admin-only destructive
action — same gating as the original suffix conveyed).
- Hard delete on marketplace_item_detail (same).
- Edit link on memory_domain_detail (title-attr only before; now a
visible chip too).
Non-admin viewers never saw these affordances — the gates are
unchanged. Pure styling pass for consistency.
* fix(catalog): exclude soft-deleted data packages + memory domains from Browse
``StackResolver._fetch_entries`` and ``browse_admin`` were querying
data_packages / memory_domains without a ``deleted_at IS NULL`` guard.
A package soft-deleted via /admin/* (v54 soft-delete contract) stayed
visible on /catalog and /memory until either an Undo or a hard delete
— directly contradicting the soft-delete UX which is supposed to
remove the affordance immediately and only retain the row for the
Undo window.
The repository accessors (DataPackagesRepository.list,
MemoryDomainsRepository.list, list_packages_of_table, etc.) already
filter deleted rows; this commit brings the resolver's direct SQL in
line with that contract.
Regression test in test_stack_resolver_browse_admin.py.
* fix(catalog): Add/Remove updates full card chrome, not just button
The previous _applyStackChange flipped only the footer button label —
the card border (.is-in-stack class), top-right "In stack" badge, and
button color class (--add / --remove) stayed at their server-rendered
state. After Add the user saw the button checkmark but the rest of
the card still looked like "available, not in stack". They read this
as "the change didn't take — let me refresh".
This commit makes the optimistic update mirror what the server-side
macro renders for the new state:
* ``c.classList.toggle('is-in-stack', becameInStack)`` — flips the
border + visual state class.
* Top-right ``.stack-card__req-badge--instack`` badge is injected on
Add, removed on Remove (skipped when ``data-requirement='required'``
— that slot is owned by the Required badge).
* Button text is "Remove" / "+ Add to stack" matching the macro
(was "✓ In stack" which was visually nice but inconsistent).
* Button color class --add / --remove swaps so the destructive Remove
tint kicks in immediately.
The clone-into-My-Stack path applies the same updates so the new card
in My Stack reads identically to a server-rendered in_stack card.
Mirrored in /corporate-memory.
* fix(memory): four Devin-review bugs on /memory drill-down + manifest
PR #333 Devin review surfaced four real bugs that ship a broken
/memory experience even though the unit tests passed.
1. Manifest md5 omits is_required + content (app/api/sync.py:836-840)
_build_memory_domains_section hashed only (id|title|status) per
item. _build_per_domain_markdown routes items between "## Required"
and "## Approved" by is_required and embeds full content — so an
admin edit of either dimension left the manifest md5 unchanged,
`agnes pull` skipped the re-fetch, and the analyst kept a stale
bundle.md. Now both fields participate in the hash.
2. required_count always 0 (src/repositories/memory_domains.py)
list_items_of_domain only SELECTed (id, title, status) so the
`it.get("is_required")` in the manifest builder always evaluated
to None → required_count = 0 regardless of actual state. The
manifest builder advertised a count it could never compute. Now
projects is_required + content too (required by fix 1 anyway).
3. Vote URL 404 (memory_domain_detail.html:289-290)
Constructed `/api/memory/items/{id}/vote` but the route is
`/api/memory/{id}/vote`. Every upvote/downvote button was a
silent no-op.
4. Dismiss/undismiss URL + method both wrong (memory_domain_detail.html:296-305)
Constructed `/api/memory/items/{id}/dismiss` (extra /items/) and
/undismiss (no such route — undismiss is DELETE on /dismiss).
Both buttons silently 404'd. Now POST + DELETE on
`/api/memory/{id}/dismiss` per app/api/memory.py:635/675.
* fix: multi-agent reviewer findings — vendor-token scrubs + manifest md5 predicate + soft-delete filter
Three reviewer findings from the multi-agent review on PR #333,
fixed in-place per CLAUDE.md issue-economy rule.
Reviewer-rules (Important — vendor-agnostic OSS):
- app/main.py:218 comment: replaced 'foundryai-prod' with generic
'a customer prod instance' phrasing. Public OSS repo must not
carry customer-specific tokens (CLAUDE.md § Project conventions).
- tests/test_table_registry_v56_docs.py:70 fixture string:
replaced "user_brand_affiliation = 'groupon'" with 'acme' on
the same rule.
Reviewer-architecture (closes still-unresolved Devin 🚩 ANALYSIS):
- app/api/sync.py _build_memory_domains_section: md5 hash loop now
filters items to the SAME predicate the bundle renderer uses
(is_required OR status='approved'). Pre-fix the hash iterated ALL
items but _build_per_domain_markdown only rendered the union of
required items + approved-non-required items — so an admin edit
to a pending/rejected non-required item flipped the md5 against
an identical-bytes bundle, triggering a wasteful re-fetch on
every analyst's next 'agnes pull'. The earlier commit fixed the
hash-input fields (is_required + content); this closes the
set-of-items asymmetry Devin separately flagged.
Reviewer-RBAC (minor cleanup):
- app/resource_types.py _data_package_blocks and _memory_domain_blocks
now filter 'WHERE deleted_at IS NULL' (v54 soft-delete column) so
the /admin/access UI doesn't surface soft-deleted entities as
grantable. Mirrors the existing filter on _recipe_blocks. No
security leak pre-fix (resolver double-filters and re-checks at
serve time), just UI cleanliness.
- app/services/stack_resolver.py add_to_stack: docstring note
added explaining that authorization is enforced at the API layer
(app/api/stack.py can_access gate), not at the resolver. The
initial review suggested adding a defensive 403 here, but that
broke 5 existing tests that legitimately call add_to_stack
directly without setting up grants first; the docstring captures
the contract instead. stack() already intersects subscriptions
with current available_ids on every read, so a 'zombie' row from
a misuse never leaks into the user-facing manifest.
* release: 0.55.0 — unified Browse + My Stack (Data Packages + Memory), schema v48→v59, 3 BREAKING
1550 lines
70 KiB
HTML
1550 lines
70 KiB
HTML
{% 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; 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;
|
||
}
|
||
|
||
/* 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);
|
||
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; 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">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">
|
||
<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 => (
|
||
{'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 %}
|