feat(marketplace): rename CTA + expand submit-flow guides (#308)

Three coordinated tweaks to the publication discovery surface:

1. Action-row CTA on /marketplace?tab=curated reads 'Submit a skill
   or plugin' instead of 'Submit a plugin'. Skills are first-class
   citizens of the curated shelf; the old wording made them feel
   like an afterthought. Same rename in the empty-state JS innerHTML
   so the two paths can't drift.

2. Curated guide page (/marketplace/guide/curated) expanded from a
   4-line stub into a 3-step ordered list documenting the Named
   Curator handoff (find curator → handoff → publish + lifecycle).
   New '.guide-fastpath' callout block points users at the Flea
   Market when they want lighter review-bar / faster path. Primary
   CTA at the bottom of the curated guide now links to the flea
   guide too, so users who skim past the fast-path callout still
   see the escape hatch.

3. Flea guide page (/marketplace/guide/flea) expanded from a
   3-line stub into a 4-step ordered list (package → upload via
   form → automated review → published). Documents the actual
   /store/new flow + the automated guardrails (manifest, content
   quality, prompt-injection scan) so users know what 'self-service'
   actually means before they upload.

Route titles updated to match: 'Submit a skill or plugin to
Curated Marketplace'.

New file: tests/test_web_marketplace_guide.py — three tests covering
the CTA rename, the curated guide's structural elements (Named
Curators lede, 3 steps, fastpath callout, primary-CTA href), and
the flea guide's structural elements (4 steps, no fastpath
asymmetry, /store/new primary CTA).
This commit is contained in:
Vojtech 2026-05-14 21:44:33 +04:00 committed by GitHub
parent 17159bfad9
commit cb13f80241
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 280 additions and 25 deletions

View file

@ -1525,7 +1525,7 @@ async def marketplace_guide_curated(
): ):
ctx = _build_context( ctx = _build_context(
request, user=user, request, user=user,
guide_title="Submit a plugin to Curated Marketplace", guide_title="Submit a skill or plugin to Curated Marketplace",
guide_kind="curated", guide_kind="curated",
) )
return templates.TemplateResponse(request, "marketplace_guide.html", ctx) return templates.TemplateResponse(request, "marketplace_guide.html", ctx)

View file

@ -440,7 +440,7 @@
</button> </button>
</div> </div>
<div class="mp-actions"> <div class="mp-actions">
<a class="btn btn-secondary" data-actions-for="curated" href="/marketplace/guide/curated">Submit a plugin</a> <a class="btn btn-secondary" data-actions-for="curated" href="/marketplace/guide/curated">Submit a skill or plugin</a>
<!-- Flea has a self-service +Upload button below — no second <!-- Flea has a self-service +Upload button below — no second
"how to" CTA needed. Anyone uploads via the form directly. --> "how to" CTA needed. Anyone uploads via the form directly. -->
<a class="btn btn-primary" data-actions-for="flea" href="/store/new" hidden>+ Upload</a> <a class="btn btn-primary" data-actions-for="flea" href="/store/new" hidden>+ Upload</a>
@ -779,7 +779,7 @@ function fillEmptyState() {
if (state.tab === 'curated') { if (state.tab === 'curated') {
t.textContent = 'No curated plugins available.'; t.textContent = 'No curated plugins available.';
b.textContent = 'Ask your admin to grant your group access.'; b.textContent = 'Ask your admin to grant your group access.';
c.innerHTML = `<a href="/marketplace/guide/curated">Submit a plugin →</a>`; c.innerHTML = `<a href="/marketplace/guide/curated">Submit a skill or plugin →</a>`;
} else if (state.tab === 'flea') { } else if (state.tab === 'flea') {
t.textContent = 'No community entities yet.'; t.textContent = 'No community entities yet.';
b.textContent = 'Be the first to share something.'; b.textContent = 'Be the first to share something.';

View file

@ -71,6 +71,39 @@
/* Flea variant uses purple step bubbles (matches flea badge accent). */ /* Flea variant uses purple step bubbles (matches flea badge accent). */
.guide-page[data-kind="flea"] .guide-steps li::before { background: #6D28D9; } .guide-page[data-kind="flea"] .guide-steps li::before { background: #6D28D9; }
/* Fast-path callout — visually distinct from the numbered steps so
readers spot "wait, there's a quicker option" without re-reading
the whole page. Lives only on the curated guide; flea has no
equivalent escape hatch (it's already the fast path). */
.guide-fastpath {
margin: 16px 0 28px;
padding: 18px 22px;
background: linear-gradient(135deg, rgba(109, 40, 217, 0.06), rgba(109, 40, 217, 0.02));
border: 1px solid rgba(109, 40, 217, 0.25);
border-left: 4px solid #6D28D9;
border-radius: 12px;
}
.guide-fastpath h3 {
margin: 0 0 8px;
font-size: 15px; font-weight: 700;
color: var(--text-primary);
}
.guide-fastpath p {
margin: 0 0 12px;
font-size: 14px; line-height: 1.6;
color: var(--text-secondary);
}
.guide-fastpath p strong { color: var(--text-primary); }
.guide-fastpath a.fastpath-cta {
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 14px;
background: #6D28D9; color: #fff;
border-radius: 8px;
font-size: 13px; font-weight: 500;
text-decoration: none;
}
.guide-fastpath a.fastpath-cta:hover { background: #5B21B6; }
/* ── Section block ──────────────────────────────────────────────── */ /* ── Section block ──────────────────────────────────────────────── */
.guide-section { .guide-section {
background: var(--card-bg); background: var(--card-bg);
@ -130,32 +163,114 @@
<h1>{{ guide_title }}</h1> <h1>{{ guide_title }}</h1>
{% if guide_kind == 'curated' %} {% if guide_kind == 'curated' %}
{# Curated submission guide — body intentionally left minimal; full {# Curated submission guide — publish-by-curator flow. Named Curators
content to be authored separately. The CTA buttons at the bottom of are the gatekeepers (security / telemetry hygiene / docs bar). The
the page still let the user jump back to the Curated tab or browse fast-path callout points users at the Flea Market when they want
the Flea Market as a self-service alternative. #} reach without the review wait. #}
<p class="lede"> <p class="lede">
The Curated Marketplace is publish-by-curator only. To get your skill, Curated Marketplace submissions go through <strong>Named Curators</strong>
agent, or plugin listed, hand it off to a curator who reviews and — folks accountable for keeping the bar (security, telemetry hygiene,
publishes it on your behalf. documentation) consistent across what ships on this shelf.
</p> </p>
{% else %}
{# Flea publishing guide — body intentionally left minimal; full content
to be authored separately. The CTA buttons at the bottom of the page
still let the user jump to /store/new or browse the Flea Market. #}
<p class="lede">
The Flea Market is self-service. Anyone in the company can upload a
skill, agent, or plugin.
</p>
{% endif %}
<div class="guide-cta"> <ol class="guide-steps">
<a class="primary" href="{{ '/store/new' if guide_kind == 'flea' else '/marketplace?tab=curated' }}"> <li>
{{ '+ Upload to Flea Market' if guide_kind == 'flea' else '← Back to Curated' }} <strong>Find a Curator.</strong>
</a> Each plugin in the Curated Marketplace lists its curator on its detail
<a href="/marketplace?tab={{ 'flea' if guide_kind == 'curated' else 'curated' }}"> page. Reach out to any curator whose published work overlaps with what
{{ 'Browse Flea Market' if guide_kind == 'curated' else 'Browse Curated' }} → you're shipping — they're the most likely owner. The
<em>See all curators →</em> link on the
<a href="/marketplace?tab=curated">Curated tab</a> is the directory entry.
<span class="note">If no curator's domain matches, ask the
{{ instance_brand }} platform team — they'll either adopt the
submission or steer it to the right person.</span>
</li>
<li>
<strong>Hand off your skill or plugin.</strong>
Share the source repo (or a zipped bundle) plus a one-paragraph intro
covering <em>what it does</em>, <em>who it's for</em>, and
<em>what data it touches</em>. The curator runs it through the review
bar (manifest sanity, prompt-injection surface, doc completeness,
telemetry posture).
</li>
<li>
<strong>Curator publishes.</strong>
Once they accept, the curator commits the manifest entry to the
curated repo. Your plugin appears on the Curated tab on the next
nightly catalog sync (within ~24&nbsp;h of merge).
<span class="note">Iteration after publish goes through the same
curator — they own the entry's lifecycle (version bumps,
deprecation, takedown).</span>
</li>
</ol>
<div class="guide-fastpath">
<h3>&#x26A1; Want it live in minutes instead of days?</h3>
<p>
The <strong>Flea Market</strong> is the self-service shelf — anyone in
the company can upload a skill, agent, or plugin without going
through a curator. Same Claude Code reach, faster path, lighter
review bar (automated guardrails only; no human gatekeeper).
</p>
<a class="fastpath-cta" href="/marketplace/guide/flea">
Submit a skill to Flea Market &rarr;
</a> </a>
</div> </div>
<div class="guide-cta">
<a class="primary" href="/marketplace/guide/flea">Submit a skill to Flea Market &rarr;</a>
<a href="/marketplace?tab=curated">&larr; Back to Curated</a>
</div>
{% else %}
{# Flea publishing guide — self-service flow. No human gatekeeper,
automated guardrails only (content quality + security review run
server-side at submission time). Lives at /store/new. #}
<p class="lede">
The Flea Market is self-service. Anyone in the company can upload a
skill, agent, or plugin — no curator handoff, no review queue.
Automated guardrails check the manifest, content quality, and
prompt-injection surface at submission time.
</p>
<ol class="guide-steps">
<li>
<strong>Package what you're shipping.</strong>
A skill is a single <code>SKILL.md</code> (front-matter + body); an
agent is an <code>AGENT.md</code>; a plugin is a folder with a
<code>plugin.json</code> manifest and any commands / agents / skills
it bundles. Zip the folder (or point at a git URL containing it).
<span class="note">If you're starting fresh, the
<a href="/marketplace/format-guide">format guide</a> documents each
shape with copy-pasteable examples.</span>
</li>
<li>
<strong>Upload via the form.</strong>
Head to <a href="/store/new"><code>/store/new</code></a>. Drop the
zip (or paste the git URL), fill the metadata form (name, short
description, what data it touches, who it's for), submit.
<span class="note">The <em>+ Upload</em> button on the Flea Market
tab is the same form.</span>
</li>
<li>
<strong>Automated review.</strong>
Server runs content-quality checks (manifest completeness, prose
length, distinct-word floor) and a prompt-injection scan. Most
submissions pass within seconds. Failures surface inline with the
exact field to fix.
</li>
<li>
<strong>Published.</strong>
Once green, your submission goes live on the Flea Market tab
immediately — no nightly-sync delay. Iteration goes through the
same form: upload a new version, the form recognises the slug
and bumps the version pointer.
</li>
</ol>
<div class="guide-cta">
<a class="primary" href="/store/new">+ Upload to Flea Market</a>
<a href="/marketplace?tab=curated">Browse Curated &rarr;</a>
</div>
{% endif %}
</div> </div>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,140 @@
"""GET /marketplace/guide/{curated,flea} — submission flow guides.
Both routes are authed (`get_current_user` dependency). The curated guide
documents the Named Curator handoff and has a fast-path callout pointing
at the flea self-service guide; the flea guide documents the /store/new
upload flow. Together with the action-row CTA on /marketplace?tab=curated,
this trio is the discovery surface for "how do I get my plugin published".
"""
from __future__ import annotations
import tempfile
import uuid
import pytest
@pytest.fixture
def fresh_db(monkeypatch):
with tempfile.TemporaryDirectory() as tmp:
monkeypatch.setenv("DATA_DIR", tmp)
monkeypatch.setenv("TESTING", "1")
monkeypatch.setenv("JWT_SECRET_KEY", "test-jwt-secret-key-minimum-32-chars!!")
yield tmp
def _make_user_and_session(conn, email="u@example.com"):
from src.repositories.users import UserRepository
from app.auth.jwt import create_access_token
uid = str(uuid.uuid4())
UserRepository(conn).create(id=uid, email=email, name=email.split("@")[0])
return uid, create_access_token(user_id=uid, email=email)
def _client():
from fastapi.testclient import TestClient
from app.main import app
return TestClient(app)
def test_marketplace_curated_tab_cta_text(fresh_db):
"""The action-row CTA on /marketplace?tab=curated reads
'Submit a skill or plugin' (renamed from 'Submit a plugin' so skills
aren't an afterthought) and links to the curated guide. Empty-state
fallback in JS uses the same string so both surfaces stay in sync."""
from src.db import get_system_db, close_system_db
conn = get_system_db()
try:
_, sess = _make_user_and_session(conn)
finally:
conn.close()
close_system_db()
body = _client().get(
"/marketplace?tab=curated", cookies={"access_token": sess}
).text
# Action-row anchor — primary discovery path.
assert (
'<a class="btn btn-secondary" data-actions-for="curated" '
'href="/marketplace/guide/curated">Submit a skill or plugin</a>'
) in body
# Empty-state JS innerHTML — same string, no drift.
assert "Submit a skill or plugin →" in body
# Old wording must be gone — guards against partial rename.
assert ">Submit a plugin<" not in body
def test_marketplace_guide_curated_page(fresh_db):
"""Curated guide page documents the Named Curator handoff. Three-step
flow (find handoff publish) lives inside `.guide-steps`. The
fast-path callout points users at the flea guide as the lighter
review-bar alternative; the primary CTA at the bottom does the same
so users who skim past the callout still see the escape hatch."""
from src.db import get_system_db, close_system_db
conn = get_system_db()
try:
_, sess = _make_user_and_session(conn)
finally:
conn.close()
close_system_db()
resp = _client().get(
"/marketplace/guide/curated", cookies={"access_token": sess}
)
assert resp.status_code == 200
body = resp.text
# Title carries the new 'skill or plugin' wording.
assert "Submit a skill or plugin to Curated Marketplace" in body
# Lede surfaces the gatekeeping concept.
assert "Named Curators" in body
# Three-step ordered list under `.guide-steps`.
assert '<ol class="guide-steps">' in body
assert "Find a Curator" in body
assert "Hand off your skill or plugin" in body
assert "Curator publishes" in body
# Fast-path callout exists and the CTA inside it points at the flea
# guide (NOT /store/new directly — we want users to read the flea
# context before they upload).
assert '<div class="guide-fastpath">' in body
assert 'href="/marketplace/guide/flea"' in body
# Primary CTA at the bottom also surfaces the flea path.
assert 'class="primary" href="/marketplace/guide/flea"' in body
def test_marketplace_guide_flea_page(fresh_db):
"""Flea guide documents the /store/new self-service flow. Four-step
body (package upload automated review published) replaces the
earlier stub. Primary CTA goes directly to /store/new since users
landing on the flea guide have already chosen the self-service path."""
from src.db import get_system_db, close_system_db
conn = get_system_db()
try:
_, sess = _make_user_and_session(conn)
finally:
conn.close()
close_system_db()
resp = _client().get(
"/marketplace/guide/flea", cookies={"access_token": sess}
)
assert resp.status_code == 200
body = resp.text
assert "Upload to Flea Market" in body
# Four-step ordered list (no fast-path callout on flea — it IS the
# fast path, the curated guide is what links here).
assert '<ol class="guide-steps">' in body
assert "Package what you" in body
assert "Upload via the form" in body
assert "Automated review" in body
assert "Published" in body
# Primary CTA goes straight to /store/new (flea is one click away
# from being live, no intermediate handoff).
assert 'class="primary" href="/store/new"' in body
# No fast-path callout here — sanity check the asymmetry sticks.
assert '<div class="guide-fastpath">' not in body