agnes-the-ai-analyst/app/web/templates/store_upload.html
Vojtech 6a4b3ba461
fix(store-upload): Next/Back/Finish buttons missing .btn base class (#310)
The wizard nav buttons used class="btn-primary" / "btn-secondary"
without the .btn base class, so the padding (10px 20px),
border-radius (8px), font-size, and inline-flex centering rules from
.btn never applied. Buttons rendered as ~18px-tall colored boxes with
no padding (visible mismatch against the sibling Cancel <a> which
correctly used class="btn btn-secondary").

Added .btn to all three buttons (#next-btn, #back-btn, #finish-btn).
No CSS change — purely a markup fix.

Playwright before: next.padding="0px" borderRadius="0px" height=18
Playwright after:  next.padding="10px 20px" borderRadius="8px" height=38
2026-05-14 19:49:13 +00:00

1150 lines
48 KiB
HTML

{% extends "base.html" %}
{% block title %}Upload to Store — {{ config.INSTANCE_NAME }}{% endblock %}
{% block content %}
<style>
/* ── Container override ────────────────────────────────────────── */
/* Width + padding come from .page-shell (style-custom.css) — same
1280px container as /dashboard, /marketplace, /admin/* peers. */
.container:has(.upload-page) > main { margin: 0; padding: 0; }
/* ── Hero (mirrors /setup) ─────────────────────────────────────── */
.upload-hero {
background: linear-gradient(135deg, #0073D1 0%, #0056A3 100%);
border-radius: 12px;
padding: 32px 36px;
margin-bottom: 24px;
box-shadow: 0 4px 16px rgba(0, 115, 209, 0.2);
color: white;
}
.upload-hero .eyebrow {
font-size: 11px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.8px; color: rgba(255, 255, 255, 0.75);
margin-bottom: 10px;
}
.upload-hero h1 {
font-size: 30px; font-weight: 700; margin: 0 0 8px;
color: #fff; letter-spacing: -0.4px;
}
.upload-hero .sub { font-size: 14px; color: rgba(255,255,255,0.85); line-height: 1.6; }
.upload-hero .meta { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
.upload-hero .pill {
background: rgba(255,255,255,0.15); padding: 6px 12px;
border-radius: 6px; font-family: var(--font-mono); font-size: 12px; color: #fff;
}
/* ── Progress bar (4-segment style, matches first-time-setup) ──── */
.progress {
display: flex; gap: 8px; margin-bottom: 20px;
}
.progress .seg {
flex: 1; height: 4px; border-radius: 2px;
background: var(--border, #e5e7eb);
transition: background 0.15s ease;
}
.progress .seg.is-done { background: var(--primary, #0073D1); }
/* ── Card (mirrors /setup) ─────────────────────────────────────── */
.card {
background: var(--surface, #fff);
border: 1px solid var(--border, #e5e7eb);
border-radius: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
overflow: hidden;
margin-bottom: 20px;
}
.card-header { padding: 22px 24px 0; display: flex; align-items: center; gap: 12px; }
.card-body { padding: 16px 24px 24px; }
.step-num {
width: 26px; height: 26px;
background: var(--primary, #0073D1); color: #fff;
border-radius: 50%; display: inline-flex; align-items: center;
justify-content: center; font-size: 12px; font-weight: 700; flex-shrink: 0;
}
.card-title { font-size: 16px; font-weight: 600; color: var(--text-primary, #111827); }
.card-sub {
font-size: 13px; color: var(--text-secondary, #6b7280);
line-height: 1.6; margin: 0 0 14px;
}
.card-sub code {
background: var(--border-light, #f3f4f6); padding: 1px 6px;
border-radius: 4px; font-family: var(--font-mono); font-size: 12px;
color: var(--text-primary, #111827);
}
/* ── Form bits ─────────────────────────────────────────────────── */
.field { margin-bottom: 16px; }
.field-label {
display: block; font-size: 13px; font-weight: 500;
color: var(--text-primary, #111827); margin-bottom: 6px;
}
.field-hint { font-size: 12px; color: var(--text-secondary, #6b7280); margin-top: 6px; }
.field input[type=text],
.field input[type=url],
.field textarea,
.field select {
width: 100%; padding: 10px 12px;
border: 1px solid var(--border, #d1d5db); border-radius: 8px;
font-size: 14px; font-family: var(--font-primary, inherit);
box-sizing: border-box; background: #fff;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.field textarea { min-height: 90px; resize: vertical; }
.field input[type=text]:focus,
.field input[type=url]:focus,
.field textarea:focus,
.field select:focus {
outline: none; border-color: var(--primary, #0073D1);
box-shadow: 0 0 0 3px rgba(0, 115, 209, 0.12);
}
/* ── Type tiles (radio cards) ──────────────────────────────────── */
.type-tiles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.type-tiles label {
cursor: pointer; padding: 14px 12px;
border: 1px solid var(--border, #e5e7eb); border-radius: 10px;
text-align: center; transition: all 0.15s ease;
background: #fff;
}
.type-tiles label:hover { border-color: #c7d2fe; }
.type-tiles label.is-active {
border-color: var(--primary, #0073D1);
background: linear-gradient(180deg, rgba(0,115,209,0.06), rgba(0,115,209,0.02));
box-shadow: 0 0 0 3px rgba(0, 115, 209, 0.12);
}
.type-tiles input[type=radio] { display: none; }
.type-tiles .type-name {
font-weight: 600; font-size: 14px; color: var(--text-primary, #111827); margin-bottom: 4px;
}
.type-tiles .type-hint { font-size: 11px; color: var(--text-secondary, #6b7280); line-height: 1.4; }
/* ── ZIP drop zone (file input wrapper) ────────────────────────── */
.file-drop {
border: 1.5px dashed var(--border, #d1d5db);
border-radius: 10px; padding: 18px;
background: var(--background, #f9fafb);
display: flex; align-items: center; gap: 12px;
transition: all 0.15s ease;
}
.file-drop:hover { border-color: var(--primary, #0073D1); background: #fff; }
.file-drop.is-dragover {
border-color: var(--primary, #0073D1); background: #fff;
box-shadow: 0 0 0 4px rgba(0, 115, 209, 0.15);
transform: scale(1.005);
}
.file-drop.has-file { border-style: solid; border-color: #10b981; background: #f0fdf4; }
.file-drop .icon {
width: 36px; height: 36px; border-radius: 8px;
background: rgba(0, 115, 209, 0.1); color: var(--primary, #0073D1);
display: flex; align-items: center; justify-content: center;
flex-shrink: 0; font-size: 16px;
}
.file-drop.has-file .icon { background: rgba(16, 185, 129, 0.1); color: #10b981; }
.file-drop .file-info { flex: 1; min-width: 0; }
.file-drop .file-info .label { font-size: 13px; font-weight: 500; color: var(--text-primary, #111827); }
.file-drop .file-info .label .filename { color: var(--primary, #0073D1); margin-left: 6px; }
.file-drop.has-file .file-info .label { color: #047857; }
.file-drop .file-info .meta { font-size: 12px; color: var(--text-secondary, #6b7280); margin-top: 2px; }
.file-drop input[type=file] { display: none; }
.file-drop button {
appearance: none; padding: 7px 14px;
border: 1px solid var(--border, #d1d5db); background: #fff;
color: var(--text-primary, #111827); border-radius: 8px;
font-size: 12px; font-weight: 500; cursor: pointer;
flex-shrink: 0; font-family: var(--font-primary, inherit);
}
.file-drop button:hover { border-color: var(--primary, #0073D1); color: var(--primary, #0073D1); }
/* ── Doc list ──────────────────────────────────────────────────── */
.doc-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; }
.doc-item {
display: flex; align-items: center; gap: 10px; justify-content: space-between;
padding: 8px 12px; border: 1px solid var(--border, #e5e7eb);
border-radius: 8px; background: var(--background, #f9fafb); font-size: 13px;
}
.doc-item .name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-primary, #111827); }
.doc-item .size { color: var(--text-secondary, #6b7280); flex-shrink: 0; font-size: 12px; }
.doc-item button {
appearance: none; border: none; background: transparent;
color: #b91c1c; cursor: pointer; font-size: 16px;
padding: 0 4px; line-height: 1;
}
.doc-add {
appearance: none; padding: 8px 14px;
border: 1px dashed var(--border, #d1d5db); background: transparent;
color: var(--text-secondary, #6b7280); border-radius: 8px;
font-size: 13px; cursor: pointer; align-self: flex-start;
font-family: var(--font-primary, inherit);
}
.doc-add:hover { border-color: var(--primary, #0073D1); color: var(--primary, #0073D1); }
/* ── Buttons ───────────────────────────────────────────────────── */
.actions { display: flex; gap: 10px; align-items: center; margin-top: 8px; }
/* ── Status banner ─────────────────────────────────────────────── */
/* `display: flex` on .banner overrides the user-agent default for the
`hidden` attribute, leaving the banner visible even with no error.
Force the override here so the empty banner stays out of the layout. */
.banner[hidden] { display: none !important; }
.banner {
padding: 12px 16px; border-radius: 8px; margin-bottom: 16px;
font-size: 13px; line-height: 1.5; display: flex; align-items: flex-start; gap: 10px;
}
.banner.error { background: #fef2f2; color: #b91c1c; border: 1px solid #fecaca; }
.banner.success { background: #f0fdf4; color: #16a34a; border: 1px solid #bbf7d0; }
.banner .ico { flex-shrink: 0; font-size: 16px; line-height: 1.5; }
/* Preserve newlines in structured upload-error messages (multi-line
finding lists). textContent assignment wouldn't render them
otherwise. */
.banner > span { white-space: pre-wrap; }
/* ── Step swap ─────────────────────────────────────────────────── */
.step { display: none; }
.step.is-active { display: block; }
/* ── Description char counter + guidelines disclosure ──────────── */
.desc-counter {
margin-top: 4px; font-size: 12px;
color: var(--text-secondary, #6b7280);
font-family: var(--font-mono);
}
.desc-counter.ok { color: #16a34a; }
.desc-counter.warn { color: #b45309; }
.guidelines {
margin-bottom: 14px;
background: var(--background, #f9fafb);
border: 1px solid var(--border, #e5e7eb);
border-radius: 10px;
overflow: hidden;
/* base.html has a sticky top bar; scroll target needs headroom so
the chevron lands BELOW the bar, not behind it. */
scroll-margin-top: 80px;
}
.guidelines-toggle {
appearance: none; -webkit-appearance: none;
width: 100%; text-align: left;
cursor: pointer;
padding: 12px 14px;
border: 0; background: transparent;
font-size: 13px; font-weight: 600;
color: var(--text-primary, #111827);
font-family: inherit;
user-select: none;
display: flex; align-items: center; gap: 8px;
transition: background 0.12s ease;
/* Same headroom as the wrap so direct .scrollIntoView() on the
button clears the sticky page header. */
scroll-margin-top: 80px;
}
.guidelines-toggle:hover { background: rgba(0, 115, 209, 0.06); }
.guidelines-toggle::before {
content: "▸"; transition: transform 0.15s ease;
display: inline-block; color: var(--primary, #0073D1); font-weight: 700;
}
.guidelines-toggle[aria-expanded="true"] {
background: rgba(0, 115, 209, 0.08);
border-bottom: 1px solid var(--border, #e5e7eb);
}
.guidelines-toggle[aria-expanded="true"]::before { transform: rotate(90deg); }
.guidelines-body {
padding: 10px 14px 14px 30px; font-size: 13px;
color: var(--text-primary, #374151); line-height: 1.6;
background: #ffffff;
}
.guidelines-body[hidden] { display: none; }
.guidelines-body p { margin: 6px 0; }
.guidelines-body ul { margin: 4px 0; padding-left: 18px; }
.guidelines-body code {
background: var(--border-light, #eef2f7); padding: 1px 5px;
border-radius: 4px; font-size: 12px; color: var(--text-primary, #111827);
}
/* ── Component preview table ───────────────────────────────────── */
.comp-list {
margin-top: 12px; border: 1px solid var(--border, #e5e7eb);
border-radius: 8px; overflow: hidden;
font-size: 13px;
}
.comp-list-header {
background: var(--background, #f9fafb);
padding: 8px 12px; font-weight: 600; font-size: 12px;
color: var(--text-secondary, #6b7280); border-bottom: 1px solid var(--border, #e5e7eb);
text-transform: uppercase; letter-spacing: 0.4px;
}
.comp-row {
padding: 8px 12px;
border-top: 1px solid var(--border, #e5e7eb);
display: flex; gap: 10px; align-items: flex-start;
}
.comp-row:first-of-type { border-top: none; }
.comp-dot {
flex-shrink: 0; width: 10px; height: 10px; border-radius: 50%;
margin-top: 6px;
}
.comp-dot.ok { background: #16a34a; }
.comp-dot.bad { background: #dc2626; }
.comp-text { flex: 1; min-width: 0; }
.comp-text .file {
font-family: var(--font-mono); font-size: 12px;
color: var(--text-primary, #111827);
}
.comp-text .type {
display: inline-block; margin-left: 6px; padding: 1px 6px;
border-radius: 4px; background: rgba(0, 115, 209, 0.08);
color: var(--primary, #0073D1); font-size: 11px;
}
.comp-text .preview {
margin-top: 3px; color: var(--text-secondary, #6b7280); font-size: 12px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.comp-text .issue {
margin-top: 3px; color: #b91c1c; font-size: 12px;
}
</style>
<div class="upload-page page-shell">
<div class="upload-hero">
<div class="eyebrow">Store</div>
<h1>Upload an entity</h1>
<p class="sub">
Share a skill, agent, or plugin with everyone on this instance.
Display name will be suffixed with
<code style="background: rgba(255,255,255,0.18); padding: 1px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 12px;">-by-{{ session.user.email.split('@')[0] }}</code>
so it doesn't collide.
</p>
</div>
<div class="progress">
<div class="seg is-done" id="seg-1"></div>
<div class="seg" id="seg-2"></div>
</div>
<div id="banner" class="banner error" hidden>
<span class="ico">!</span><span id="banner-text"></span>
</div>
<div id="banner-actions" hidden style="margin: -10px 0 14px 0; font-size: 13px;">
<a href="/store/examples" target="_blank" rel="noopener"
style="color: var(--primary, #0073D1); text-decoration: none; font-weight: 500;">
See submission examples ↗
</a>
</div>
<!-- ─── Step 1: Type + ZIP ──────────────────────────────────────── -->
<div id="step-1" class="step is-active">
<div class="card">
<div class="card-header">
<div class="step-num">1</div>
<div class="card-title">Type &amp; ZIP</div>
</div>
<div class="card-body">
<p class="card-sub">Pick what you're uploading and the ZIP archive.
The server validates the layout when you click <code>Next</code>.</p>
<div id="guidelines" class="guidelines">
<button type="button" class="guidelines-toggle" id="guidelines-toggle"
aria-expanded="false" aria-controls="guidelines-body">
Before you upload — what passes review
</button>
<div class="guidelines-body" id="guidelines-body" hidden>
<p style="margin-top: 4px;">
Every component description (plugin, agents, skills, commands)
is the trigger string Claude reads to decide whether to invoke
it. Vague or missing descriptions get rejected even when the
code is fine.
</p>
<p><strong>The bar:</strong></p>
<ul>
<li>Each description ≥ <strong>{{ guardrail.min_description_chars|default(60) }} characters</strong> (commands ≥ {{ guardrail.min_command_description_chars|default(25) }}). Aim for one complete sentence — too short and the assistant can't tell when to use it.</li>
<li>At least {{ guardrail.min_distinct_words|default(5) }} distinct words</li>
<li>No <code>TODO</code> / <code>TBD</code> / template placeholders</li>
<li>Action-oriented, names the trigger condition</li>
</ul>
<p><strong>Patterns that work:</strong></p>
<ul>
<li><strong>Skills</strong>: <code>Use when &lt;trigger&gt;&lt;what it does&gt;</code></li>
<li><strong>Agents</strong>: <code>&lt;What the agent does&gt;. Use for &lt;invocation context&gt;</code></li>
<li><strong>Plugins</strong>: one-sentence marketplace pitch</li>
<li><strong>Commands</strong>: one-verb summary of the action</li>
</ul>
<p style="margin-bottom: 4px;">
The reviewer also runs a substantive pass on descriptions
— generic ones ("a useful skill for working with data")
get flagged even when they clear the mechanical bar.
</p>
<p style="margin-top: 10px;">
<a href="/store/examples" target="_blank" rel="noopener"
style="color: var(--primary, #0073D1); text-decoration: none; font-weight: 500;">
See full examples ↗
</a>
</p>
</div>
</div>
<div class="field">
<label class="field-label">Type</label>
<div class="type-tiles" id="type-tiles">
<label class="is-active">
<input type="radio" name="type" value="skill" checked>
<div class="type-name">Skill</div>
<div class="type-hint">Folder with SKILL.md</div>
</label>
<label>
<input type="radio" name="type" value="agent">
<div class="type-name">Agent</div>
<div class="type-hint">.md file with name + description frontmatter</div>
</label>
<label>
<input type="radio" name="type" value="plugin">
<div class="type-name">Plugin</div>
<div class="type-hint">Directory with .claude-plugin/plugin.json</div>
</label>
</div>
</div>
<div class="field">
<label class="field-label">ZIP archive</label>
{# Drop zone uses a <div> rather than <label> so the implicit
label→input click pickup doesn't fire on top of our explicit
JS handler — that combination opened the file picker twice. #}
<div class="file-drop" id="zip-drop">
<div class="icon">📦</div>
<div class="file-info">
<div class="label" id="zip-label">No file selected</div>
<div class="meta" id="zip-meta">Drag & drop a .zip here, or click Choose file</div>
</div>
<input type="file" id="zip" accept=".zip">
<button type="button" id="zip-pick">Choose file</button>
</div>
</div>
<div class="actions">
<button type="button" class="btn btn-primary" id="next-btn">Next →</button>
<a href="/marketplace?tab=flea" class="btn btn-secondary">Cancel</a>
</div>
</div>
</div>
</div>
<!-- ─── Step 2: Details ─────────────────────────────────────────── -->
<div id="step-2" class="step">
<div class="card">
<div class="card-header">
<div class="step-num">2</div>
<div class="card-title">Details</div>
</div>
<div class="card-body">
<p class="card-sub">Pre-filled from the ZIP's frontmatter — change anything you want.
Name and description are required; everything below is optional.
</p>
<div id="guidelines-2" class="guidelines">
<button type="button" class="guidelines-toggle" id="guidelines-toggle-2"
aria-expanded="false" aria-controls="guidelines-body-2">
Before you upload — what passes review
</button>
<div class="guidelines-body" id="guidelines-body-2" hidden>
<p style="margin-top: 4px;">
Every component description (plugin, agents, skills, commands)
is the trigger string Claude reads to decide whether to invoke
it. Vague or missing descriptions get rejected even when the
code is fine.
</p>
<p><strong>The bar:</strong></p>
<ul>
<li>Each description ≥ <strong>{{ guardrail.min_description_chars|default(60) }} characters</strong> (commands ≥ {{ guardrail.min_command_description_chars|default(25) }}). Aim for one complete sentence — too short and the assistant can't tell when to use it.</li>
<li>At least {{ guardrail.min_distinct_words|default(5) }} distinct words</li>
<li>No <code>TODO</code> / <code>TBD</code> / template placeholders</li>
<li>Action-oriented, names the trigger condition</li>
</ul>
<p style="margin-top: 10px;">
<a href="/store/examples" target="_blank" rel="noopener"
style="color: var(--primary, #0073D1); text-decoration: none; font-weight: 500;">
See full examples ↗
</a>
</p>
</div>
</div>
<div class="field">
<label class="field-label" for="name">Name</label>
<input type="text" id="name" required placeholder="my-awesome-skill">
<div class="field-hint">Lowercase letters, digits, hyphens. Max 64 characters.</div>
</div>
<div class="field">
<label class="field-label" for="description">Description</label>
<textarea id="description"
placeholder="Use when reviewing pull requests to flag missing tests, weak assertions, and brittle implementation coupling."></textarea>
<div id="desc-counter" class="desc-counter">0 / 30 minimum</div>
<div class="field-hint">
Shown on the marketplace tile. Same bar as the per-component
descriptions —
<a href="#guidelines-2" id="open-guidelines-link"
style="color: var(--primary, #0073D1); text-decoration: underline;">see Before you upload</a>.
</div>
</div>
<div id="comp-preview" class="field" hidden>
<label class="field-label">Bundle components</label>
<div id="comp-list" class="comp-list"></div>
<div class="field-hint">
Green dots pass the mechanical description bar. The
substantive review runs after you Finish — bundles can still
be flagged for vague descriptions even when every dot is green.
</div>
</div>
<div class="field">
<label class="field-label" for="category">Category</label>
<select id="category">
<option value="">— None —</option>
{% for cat in categories %}
<option value="{{ cat }}">{{ cat }}</option>
{% endfor %}
</select>
<div class="field-hint">Subject area of your entity — helps people filter the Store.</div>
</div>
<div class="field">
<label class="field-label">Cover photo <span style="color:var(--text-secondary,#6b7280);font-weight:400;">(optional)</span></label>
<div class="file-drop" id="photo-drop">
<div class="icon">🖼</div>
<div class="file-info">
<div class="label" id="photo-label">No photo</div>
<div class="meta">Drag & drop or click Choose · PNG / JPEG / WebP only · Max 5 MB</div>
</div>
<input type="file" id="photo" accept="image/jpeg,image/png,image/webp">
<button type="button" id="photo-pick">Choose</button>
</div>
</div>
<div class="field">
<label class="field-label" for="video_url">Video URL <span style="color:var(--text-secondary,#6b7280);font-weight:400;">(optional)</span></label>
<input type="url" id="video_url" placeholder="https://...">
</div>
<div class="field">
<label class="field-label">Documentation <span style="color:var(--text-secondary,#6b7280);font-weight:400;">(optional)</span></label>
<div class="doc-list" id="doc-list"></div>
<input type="file" id="doc-input" hidden
accept=".pdf,.md,.markdown,.txt,application/pdf,text/markdown,text/plain">
<button type="button" class="doc-add" id="add-doc-btn">+ Add file</button>
<div class="field-hint">PDF, Markdown (.md/.markdown), or plain text (.txt). Max 10 MB per file.</div>
</div>
<div class="actions">
<button type="button" class="btn btn-primary" id="finish-btn">Finish</button>
<button type="button" class="btn btn-secondary" id="back-btn">← Back</button>
</div>
</div>
</div>
</div>
</div>
<script>
const banner = document.getElementById('banner');
const bannerText = document.getElementById('banner-text');
const nextBtn = document.getElementById('next-btn');
const finishBtn = document.getElementById('finish-btn');
const backBtn = document.getElementById('back-btn');
const zipInput = document.getElementById('zip');
const zipPick = document.getElementById('zip-pick');
const zipDrop = document.getElementById('zip-drop');
const zipLabel = document.getElementById('zip-label');
const zipMeta = document.getElementById('zip-meta');
const photoInput = document.getElementById('photo');
const photoPick = document.getElementById('photo-pick');
const photoDrop = document.getElementById('photo-drop');
const photoLabel = document.getElementById('photo-label');
const addDocBtn = document.getElementById('add-doc-btn');
const docInput = document.getElementById('doc-input');
const docList = document.getElementById('doc-list');
let zipFile = null;
let docs = [];
// Server returns short machine codes in `detail`. Map them to human-friendly
// sentences here. Any unknown code falls back to a generic prefix + the raw
// code so debugging stays possible without leaking jargon.
const ERROR_MESSAGES = {
// Step 1 — validation
invalid_type: 'Pick one of: skill, agent, or plugin.',
zip_invalid: 'That file isn\'t a valid ZIP archive.',
zip_unsafe_path: 'The ZIP contains a path that escapes the archive — refuse to extract.',
zip_missing_skill_md: 'A Skill ZIP must contain a SKILL.md file.',
zip_missing_agent_md_with_frontmatter:
'An Agent ZIP must contain a Markdown file with name + description in YAML frontmatter.',
zip_missing_claude_plugin_json:
'A Plugin ZIP must contain a .claude-plugin/plugin.json file at its root.',
plugin_json_invalid: 'The .claude-plugin/plugin.json file is not valid JSON.',
zip_looks_like_skill:
'This ZIP contains a SKILL.md — looks like a Skill, not an Agent. Switch the type to Skill or remove SKILL.md.',
zip_looks_like_plugin:
'This ZIP contains a .claude-plugin/plugin.json — looks like a Plugin, not the type you picked. Switch the type to Plugin.',
// Step 1 + 2 — file size
file_too_large: 'File too large — max 50 MB for the ZIP, 5 MB for photos, 10 MB per doc.',
// Step 2 — metadata
missing_name:
'Name is required. Either fill it in here or add a `name:` field to your frontmatter.',
invalid_name_format:
'Name must be lowercase letters, digits, and hyphens only (max 64 characters).',
invalid_category: 'Pick a category from your own groups, or leave it blank.',
invalid_email:
'Couldn\'t derive a username from your email. Contact your administrator.',
conflict_owner_name:
'You already have a Store entity with this name. Each owner needs unique names — pick a different one or delete the existing entity first.',
// Step 2 — photo
photo_unsupported_format: 'Photo must be JPG, PNG, or WebP.',
// Other
entity_not_found: 'That entity no longer exists.',
not_owner: 'You don\'t own this entity, so you can\'t change it.',
};
function _renderManifestIssues(lines, manifest) {
const issues = (manifest && manifest.issues) || [];
for (const m of issues.slice(0, 5)) {
lines.push('• manifest: ' + m);
}
if (issues.length > 5) lines.push(' …and ' + (issues.length - 5) + ' more.');
}
function _renderContentIssues(lines, content) {
const issues = (content && content.issues) || [];
if (!issues.length) return;
// Plain-language labels — match the server-side rendering in
// _content_findings.html so the wording stays consistent.
const FIELD_LABEL = {
'frontmatter.description': 'Description (top of the file, after `description:`)',
'plugin.json.description': 'Description (in `.claude-plugin/plugin.json`)',
'description': 'Description (on the upload form)',
'body': 'Content (rest of the file after the description line)',
};
const CODE_LABEL = {
'empty': 'is missing',
'too_short': 'is too short',
'low_word_count': 'needs more distinct words',
'placeholder_text': 'still has placeholder text (TODO, template, etc.)',
'body_too_short': 'is too short',
};
const COMPONENT_LABEL = {
'skill': 'skill', 'agent': 'agent', 'plugin': 'plugin',
'command': 'command', 'submission': 'description',
};
lines.push('');
lines.push('What needs fixing:');
for (const issue of issues.slice(0, 6)) {
const comp = COMPONENT_LABEL[issue.component_type] || 'component';
const fieldLabel = FIELD_LABEL[issue.field] || issue.field || 'description';
const codeLabel = CODE_LABEL[issue.code] || (issue.code || 'issue').replace(/_/g, ' ');
const where = issue.component_type === 'submission'
? 'Description on the upload form'
: (comp.charAt(0).toUpperCase() + comp.slice(1))
+ (issue.name ? ' — ' + issue.name : '');
lines.push('• ' + where);
lines.push(' ' + fieldLabel + ' ' + codeLabel + '.');
if (issue.hint) lines.push(' ' + issue.hint);
}
if (issues.length > 6) {
lines.push(' …and ' + (issues.length - 6) + ' more.');
}
}
function _renderSecurityFindings(lines, staticSecurity) {
const findings = (staticSecurity && staticSecurity.findings) || [];
for (const f of findings.slice(0, 5)) {
const where = (f.file || '?') + ':' + (f.line || '?');
lines.push('• ' + where + ' — ' + (f.reason || f.category || 'security finding'));
}
if (findings.length > 5) lines.push(' …and ' + (findings.length - 5) + ' more.');
}
function humanizeError(detail) {
if (!detail) return 'Something went wrong. Please try again.';
// Structured detail (FastAPI wraps the dict at .detail). Shapes:
// {code: "validation_failed", checks: {manifest, content, quality}} — manifest/content fail
// {code: "security_blocked", checks: {static_security}} — static_scan deny-list hit
// {code: "submission_blocked", entity_id, checks: {...}} — legacy, kept for back-compat
// {code: "quota_exceeded", limit, blocked_in_last_24h, hint} — LLM-tier spam quota
if (typeof detail === 'object') {
const code = detail.code || '';
const checks = detail.checks || {};
if (code === 'validation_failed') {
const lines = ['Bundle needs fixing before it can be submitted.'];
_renderManifestIssues(lines, checks.manifest);
_renderContentIssues(lines, checks.content);
lines.push('');
lines.push('Fix the issues above and try again. Nothing was uploaded.');
lines.push('See /store/examples for full before/after examples (opens in new tab).');
return lines.join('\n');
}
if (code === 'security_blocked') {
const lines = ['Upload blocked: security review found risky patterns in the bundle.'];
_renderSecurityFindings(lines, checks.static_security);
lines.push('');
lines.push('Nothing was uploaded. Remove the flagged code or secrets and try again.');
lines.push('If a finding is a false positive, contact your administrator.');
return lines.join('\n');
}
if (code === 'submission_blocked') {
// Legacy server response (pre-cutover). Render same payload shape
// so refreshed pages on an older deploy still get a usable banner.
const lines = ['Upload blocked by automated checks.'];
_renderSecurityFindings(lines, checks.static_security);
_renderManifestIssues(lines, checks.manifest);
_renderContentIssues(lines, checks.content);
lines.push('');
lines.push('Fix the issues above and re-upload as a new version.');
lines.push('See /store/examples for full before/after examples (opens in new tab).');
return lines.join('\n');
}
if (code === 'quota_exceeded') {
return 'Upload blocked: too many rejected uploads in the last 24 hours '
+ '(' + (detail.blocked_in_last_24h || '?') + '/' + (detail.limit || '?') + '). '
+ (detail.hint || 'Wait for the window to reset.');
}
if (code) return 'Upload failed: ' + code;
// Unknown object shape — last resort, JSON-stringify so the user
// sees the raw payload instead of [object Object].
try { return 'Upload failed: ' + JSON.stringify(detail); }
catch (_) { return 'Upload failed.'; }
}
const s = String(detail);
if (ERROR_MESSAGES[s]) return ERROR_MESSAGES[s];
// Codes with a parameter (e.g. "unknown_type:foo")
const head = s.split(':', 1)[0];
if (ERROR_MESSAGES[head]) return ERROR_MESSAGES[head];
// Last resort — show a friendly wrapper plus the raw token.
return 'Upload failed: ' + s;
}
const bannerActions = document.getElementById('banner-actions');
// Manual toggle for the "Before you upload" disclosure. Native <details>
// behaviour was unreliable in at least one operator browser — explicit
// button + aria-expanded keeps the toggle deterministic. Renders on both
// step 1 and step 2 so the bar stays visible after Next.
function setGuidelinesOpen(btn, body, open) {
btn.setAttribute('aria-expanded', String(open));
body.hidden = !open;
}
document.querySelectorAll('.guidelines-toggle').forEach((btn) => {
const bodyId = btn.getAttribute('aria-controls');
const body = bodyId ? document.getElementById(bodyId) : null;
if (!body) return;
btn.addEventListener('click', () => {
const isOpen = btn.getAttribute('aria-expanded') === 'true';
setGuidelinesOpen(btn, body, !isOpen);
});
});
// "See Before you upload" inline link below the description textarea —
// open the step-2 disclosure programmatically so the user lands with
// the panel already expanded.
const openGuidelinesLink = document.getElementById('open-guidelines-link');
if (openGuidelinesLink) {
openGuidelinesLink.addEventListener('click', (e) => {
e.preventDefault();
const btn = document.getElementById('guidelines-toggle-2');
const body = document.getElementById('guidelines-body-2');
if (btn && body) {
setGuidelinesOpen(btn, body, true);
btn.scrollIntoView({behavior: 'smooth', block: 'start'});
}
});
}
function showError(msg, {showExamplesLink = false} = {}) {
banner.className = 'banner error';
banner.firstElementChild.textContent = '!';
bannerText.textContent = msg;
banner.hidden = false;
bannerActions.hidden = !showExamplesLink;
// Scroll the banner into view — the Finish button lives at the bottom
// of step 2, so a rejection banner that flips on at the top is
// off-screen on a tall form. Submitter needs to SEE the issues to
// act on them.
banner.scrollIntoView({behavior: 'smooth', block: 'start'});
}
function clearBanner() { banner.hidden = true; bannerActions.hidden = true; }
function showStep(n) {
document.getElementById('step-1').classList.toggle('is-active', n === 1);
document.getElementById('step-2').classList.toggle('is-active', n === 2);
document.getElementById('seg-2').classList.toggle('is-done', n >= 2);
clearBanner();
window.scrollTo({top: 0, behavior: 'smooth'});
}
// Type tiles
document.querySelectorAll('#type-tiles label').forEach(lbl => {
lbl.addEventListener('click', () => {
document.querySelectorAll('#type-tiles label').forEach(l => l.classList.remove('is-active'));
lbl.classList.add('is-active');
});
});
// Generic drop-zone wiring — used for both the ZIP archive and the cover
// photo. The two differ only in their accepted MIME/extension and the
// onAccept callback that updates page state.
function wireDropZone(dropEl, fileInput, pickBtn, validate, onAccept) {
// Click anywhere on the zone (except the explicit button — which already
// fires its own click) opens the file picker.
pickBtn.addEventListener('click', (e) => { e.stopPropagation(); fileInput.click(); });
dropEl.addEventListener('click', (e) => {
if (e.target.tagName !== 'BUTTON') fileInput.click();
});
fileInput.addEventListener('change', () => {
if (fileInput.files[0]) handle(fileInput.files[0]);
});
function handle(f) {
const err = validate(f);
if (err) { showError(err); return; }
// Sync the <input> so the form-submit fallback works too.
try {
const dt = new DataTransfer();
dt.items.add(f);
fileInput.files = dt.files;
} catch (_) {}
dropEl.classList.remove('is-dragover');
onAccept(f);
clearBanner();
}
['dragenter', 'dragover'].forEach(evt => {
dropEl.addEventListener(evt, (e) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
dropEl.classList.add('is-dragover');
});
});
['dragleave', 'dragend'].forEach(evt => {
dropEl.addEventListener(evt, (e) => {
// dragleave fires when entering a child; only un-highlight when the
// pointer actually leaves the drop zone.
if (evt === 'dragleave' && dropEl.contains(e.relatedTarget)) return;
dropEl.classList.remove('is-dragover');
});
});
dropEl.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
dropEl.classList.remove('is-dragover');
const file = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
if (file) handle(file);
});
}
// Page-level guard: dropping anywhere outside the zones shouldn't navigate
// away from the page (browser default behavior for files).
['dragover', 'drop'].forEach(evt => {
window.addEventListener(evt, (e) => { e.preventDefault(); });
});
// ZIP zone
wireDropZone(
zipDrop, zipInput, zipPick,
(f) => /\.zip$/i.test(f.name) ? null : 'Only .zip files are accepted.',
(f) => {
zipFile = f;
zipDrop.classList.add('has-file');
zipLabel.innerHTML = 'Selected:<span class="filename">' + escapeHtml(f.name) + '</span>';
zipMeta.textContent = formatSize(f.size);
},
);
// Photo zone — accepts JPG / PNG / WebP up to 5 MB.
const PHOTO_MAX = 5 * 1024 * 1024;
wireDropZone(
photoDrop, photoInput, photoPick,
(f) => {
if (!/^image\/(jpeg|png|webp)$/i.test(f.type) &&
!/\.(jpe?g|png|webp)$/i.test(f.name)) {
return 'Photo must be a JPG, PNG, or WebP image.';
}
if (f.size > PHOTO_MAX) {
return 'Photo too large — max 5 MB.';
}
return null;
},
(f) => {
photoDrop.classList.add('has-file');
photoLabel.innerHTML = 'Selected:<span class="filename">' + escapeHtml(f.name) + '</span>';
},
);
// Docs
// v32: client-side allowlist sanity check. The same allowlist is enforced
// server-side (returns HTTP 415), so this is purely UX — give the user a
// useful inline message before they submit instead of a generic error after
// the round-trip.
const ALLOWED_DOC_EXT = new Set(['.pdf', '.md', '.markdown', '.txt']);
addDocBtn.addEventListener('click', () => docInput.click());
docInput.addEventListener('change', () => {
for (const f of docInput.files) {
const lower = (f.name || '').toLowerCase();
const dot = lower.lastIndexOf('.');
const ext = dot >= 0 ? lower.slice(dot) : '';
if (!ALLOWED_DOC_EXT.has(ext)) {
alert(
'Unsupported document format: "' + f.name + '". '
+ 'Only PDF (.pdf), Markdown (.md, .markdown), and plain text (.txt) are accepted.'
);
continue;
}
docs.push(f);
}
docInput.value = '';
renderDocs();
});
function renderDocs() {
docList.innerHTML = '';
docs.forEach((f, i) => {
const item = document.createElement('div');
item.className = 'doc-item';
item.innerHTML = `
<span class="name">${escapeHtml(f.name)}</span>
<span class="size">${formatSize(f.size)}</span>
<button type="button" data-i="${i}" title="Remove">✕</button>`;
docList.appendChild(item);
});
docList.querySelectorAll('button[data-i]').forEach(btn => {
btn.addEventListener('click', () => {
docs.splice(parseInt(btn.dataset.i, 10), 1);
renderDocs();
});
});
}
// Description char counter — turns green at the configured floor so the
// submitter gets immediate feedback that they're past the bar. The server
// is the source of truth; this is purely UX. Floor is operator-configurable
// via /admin/server-config (default 60).
const DESC_MIN = {{ guardrail.min_description_chars|default(60) }}; // Live config from /admin/server-config
const descField = document.getElementById('description');
const descCounter = document.getElementById('desc-counter');
function updateDescCounter() {
const n = (descField.value || '').trim().length;
descCounter.textContent = `${n} / ${DESC_MIN} minimum`;
descCounter.classList.toggle('ok', n >= DESC_MIN);
descCounter.classList.toggle('warn', n > 0 && n < DESC_MIN);
}
descField.addEventListener('input', updateDescCounter);
// Component preview rendering. Called after /preview with the
// `components` array — shows green/red dots per component description.
// Same plain-language maps used by humanizeError above. Kept inline
// here so the renderComponents preview shows the same wording as the
// rejection banner.
const FIELD_LABEL_PREVIEW = {
'frontmatter.description': 'Description (top of the file)',
'plugin.json.description': 'Description (in plugin.json)',
'description': 'Description',
'body': 'Content (rest of the file)',
};
const CODE_LABEL_PREVIEW = {
'empty': 'is missing',
'too_short': 'is too short',
'low_word_count': 'needs more distinct words',
'placeholder_text': 'still has placeholder text',
'body_too_short': 'is too short',
};
function renderComponents(components) {
const wrap = document.getElementById('comp-preview');
const list = document.getElementById('comp-list');
list.innerHTML = '';
if (!components || components.length === 0) {
wrap.hidden = true;
return;
}
for (const c of components) {
const row = document.createElement('div');
row.className = 'comp-row';
const dot = document.createElement('div');
dot.className = 'comp-dot ' + (c.ok ? 'ok' : 'bad');
const text = document.createElement('div');
text.className = 'comp-text';
const fileLine = document.createElement('div');
const fileSpan = document.createElement('span');
fileSpan.className = 'file';
fileSpan.textContent = c.file || '';
const typeBadge = document.createElement('span');
typeBadge.className = 'type';
typeBadge.textContent = c.type;
fileLine.appendChild(fileSpan);
fileLine.appendChild(typeBadge);
text.appendChild(fileLine);
if (c.ok && c.description) {
const preview = document.createElement('div');
preview.className = 'preview';
preview.textContent = c.description;
text.appendChild(preview);
}
if (!c.ok) {
for (const issue of (c.issues || []).slice(0, 3)) {
const issLine = document.createElement('div');
issLine.className = 'issue';
const fieldLabel = FIELD_LABEL_PREVIEW[issue.field]
|| issue.field || 'Description';
const codeLabel = CODE_LABEL_PREVIEW[issue.code]
|| (issue.code || 'issue').replace(/_/g, ' ');
issLine.textContent = fieldLabel + ' ' + codeLabel + '. ' + (issue.hint || '');
text.appendChild(issLine);
}
// Single "See example ↗" link per component pointing at the
// matching anchor on /store/examples.
const anchor = c.type || 'skill';
const linkRow = document.createElement('div');
linkRow.style.marginTop = '4px';
const link = document.createElement('a');
link.href = '/store/examples#' + anchor;
link.target = '_blank';
link.rel = 'noopener';
link.textContent = 'See ' + anchor + ' example ↗';
link.style.fontSize = '12px';
link.style.color = 'var(--primary, #0073D1)';
link.style.textDecoration = 'underline';
linkRow.appendChild(link);
text.appendChild(linkRow);
}
row.appendChild(dot);
row.appendChild(text);
list.appendChild(row);
}
wrap.hidden = false;
}
// Step 1 → preview
nextBtn.addEventListener('click', async () => {
clearBanner();
if (!zipFile) { showError('Please choose a ZIP file.'); return; }
const type = document.querySelector('input[name=type]:checked').value;
nextBtn.disabled = true;
nextBtn.textContent = 'Validating…';
try {
const fd = new FormData();
fd.append('file', zipFile);
fd.append('type', type);
const res = await fetch('/api/store/entities/preview', {method: 'POST', body: fd});
if (!res.ok) {
let msg = 'Validation failed.';
try {
const j = await res.json();
if (j.detail) msg = humanizeError(j.detail);
} catch(_) {}
showError(msg);
return;
}
const preview = await res.json();
document.getElementById('name').value = preview.name || '';
document.getElementById('description').value = preview.description || '';
updateDescCounter();
renderComponents(preview.components || []);
showStep(2);
} catch (err) {
showError(String(err));
} finally {
nextBtn.disabled = false;
nextBtn.textContent = 'Next →';
}
});
backBtn.addEventListener('click', () => showStep(1));
// Step 2 → final create
finishBtn.addEventListener('click', async () => {
clearBanner();
const name = document.getElementById('name').value.trim();
if (!name) { showError('Name is required.'); return; }
function isContentBlock(detail) {
if (!detail || !detail.checks) return false;
// Show the "see examples" link when content-tier issues are the
// dominant fix needed — both new + legacy codes can carry them.
const isFixable = detail.code === 'validation_failed'
|| detail.code === 'submission_blocked';
return isFixable
&& detail.checks.content
&& (detail.checks.content.issues || []).length > 0;
}
const type = document.querySelector('input[name=type]:checked').value;
finishBtn.disabled = true;
finishBtn.textContent = 'Uploading…';
try {
const fd = new FormData();
fd.append('file', zipFile);
fd.append('type', type);
fd.append('name', name);
fd.append('description', document.getElementById('description').value);
const cat = document.getElementById('category').value;
if (cat) fd.append('category', cat);
const vurl = document.getElementById('video_url').value.trim();
if (vurl) fd.append('video_url', vurl);
const photo = document.getElementById('photo').files[0];
if (photo) fd.append('photo', photo);
for (const d of docs) fd.append('docs', d);
const res = await fetch('/api/store/entities', {method: 'POST', body: fd});
if (res.ok) {
const entity = await res.json();
window.location = `/marketplace/flea/${encodeURIComponent(entity.id)}`;
return;
}
// Inline failures (validation_failed / security_blocked) are hard
// rejections — no entity row, no bundle on disk. Render the banner
// inline and stay on step 2 so the submitter can fix and retry.
//
// The legacy ``submission_blocked`` branch is retained for one
// release cycle. Older deploys may still emit it with an
// ``entity_id`` pointing at a hidden row; redirect to the detail
// page so the existing quarantine banner UX still works. Fresh
// deploys never hit this branch.
let msg = 'Upload failed.';
let showLink = false;
try {
const j = await res.json();
const detail = j && j.detail;
const eid = detail && detail.entity_id;
if (eid && detail.code === 'submission_blocked') {
window.location = `/marketplace/flea/${encodeURIComponent(eid)}`;
return;
}
if (detail) {
msg = humanizeError(detail);
showLink = isContentBlock(detail);
}
} catch(_) {}
showError(msg, {showExamplesLink: showLink});
} catch (err) {
showError(String(err));
} finally {
finishBtn.disabled = false;
finishBtn.textContent = 'Finish';
}
});
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]));
}
function formatSize(n) {
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
return (n / 1024 / 1024).toFixed(1) + ' MB';
}
</script>
{% endblock %}