prometheus: submit page is a collapsible 4-card wizard

Three polish fixes from a publisher's view in Theseus:

1. The redundant "1 · Upload the binary" h2 under a progress indicator
   that already said "Upload" is gone. The step indicator IS the step
   content now — each step is one <article> card whose head is always
   visible (number + title + state chip) and whose body opens only when
   that step is current.

2. Upload input became a proper drag-and-drop zone. 46 px tall, dashed
   border that lights up acid on dragover (and the whole card wobbles
   1% so there's no doubt the drop will land). Click opens the file
   picker; drop handles files directly.

3. Step headers are clickable. A publisher can jump back to any
   completed step to re-check or edit (canReach() guards: step 4 can't
   be clicked if step 3 hasn't produced a signature yet). The head's
   sub-text updates as each step completes:
     1 · "Upload" → "theseus-setup.exe · 137 MiB · $1 listing fee"
     2 · "Describe" → "My App by foo.x · browsers · v1.0.0"
     3 · "Sign" → "signed · IJIEBM6U…"

Internals:
- One <article data-step="N" data-state="current|complete|pending"> per
  step. CSS keys off data-state for the open/closed body and the chip's
  colour. One setState() helper, one show(n) that walks the four and
  decides each's state based on canReach() + hasStepData(n).
- scroll-to-top replaced by scrollIntoView on the active card, so a
  publisher who jumps back to step 2 after reaching step 4 lands on the
  step-2 header, not the hero.
- acceptSignature(base64, how) is now one path used by both the Theseus
  wallet handler and the paste listener — stops the two from drifting
  on what counts as "signature captured".
This commit is contained in:
Local Dev 2026-10-23 01:17:54 +03:00
parent 8b76f16c1e
commit f5a8ed40e7

View file

@ -334,6 +334,60 @@ export function submit() {
'Upload an app to Prometheus. We scan it, generate the manifest, you sign with your BNS key and the listing goes live.',
here: '/submit',
body: `
<style>
/* Collapsible step cards for the submit wizard. The step indicator and
the step content are the same DOM — a step's "head" (number + title +
state chip) is always visible; the "body" (form, result, etc.) only
shows when the step is current. */
.wiz-step{
border:1px solid var(--line);border-radius:14px;background:var(--panel);
margin:0 0 14px;overflow:hidden;transition:border-color .15s;
}
.wiz-step[data-state="current"]{border-color:rgba(214,255,61,.35)}
.wiz-step[data-state="pending"]{opacity:.55}
.wiz-head{
display:flex;align-items:center;gap:14px;padding:16px 20px;cursor:pointer;
user-select:none;background:transparent;border:0;color:inherit;
font:inherit;text-align:left;width:100%;
}
.wiz-step[data-state="pending"] .wiz-head{cursor:not-allowed}
.wiz-num{
width:28px;height:28px;border-radius:50%;
background:rgba(214,255,61,.14);color:var(--acid);
display:grid;place-items:center;font-weight:700;font-size:14px;flex-shrink:0;
}
.wiz-step[data-state="complete"] .wiz-num{
background:var(--acid);color:#0b0e14;
}
.wiz-title{font-size:15px;font-weight:600;color:var(--ink)}
.wiz-sub{color:var(--mut);font-size:13.5px;margin-left:4px}
.wiz-chip{
margin-left:auto;font-size:11.5px;padding:3px 10px;border-radius:999px;
text-transform:uppercase;letter-spacing:.04em;
}
.wiz-state-complete{background:rgba(79,209,165,.14);color:var(--srv)}
.wiz-state-current{background:rgba(214,255,61,.14);color:var(--acid)}
.wiz-state-pending{background:rgba(139,152,169,.14);color:var(--mut)}
.wiz-body{padding:0 20px 22px;display:none}
.wiz-step[data-state="current"] .wiz-body{display:block}
/* Drag-and-drop upload zone */
.drop-zone{
display:block;padding:46px 24px;border:2px dashed var(--line);
border-radius:14px;text-align:center;cursor:pointer;background:var(--panel2);
transition:border-color .18s ease, background .18s ease, transform .18s ease;
}
.drop-zone:hover,.drop-zone.dragover{
border-color:var(--acid);background:rgba(214,255,61,.05);
}
.drop-zone.dragover{transform:scale(1.01)}
.drop-zone .drop-icon{font-size:34px;line-height:1;margin-bottom:.6rem}
.drop-zone .drop-main{color:var(--ink);font-size:15px}
.drop-zone .drop-link{color:var(--acid);text-decoration:underline}
.drop-zone .drop-small{color:var(--dim);font-size:12.5px;margin-top:.5rem;display:block}
.drop-zone input[type=file]{display:none}
</style>
<header class="hero compact">
<h1>Submit an app</h1>
<p class="tag">Upload → fill manifest → sign with your wallet → publish.
@ -342,50 +396,56 @@ export function submit() {
<div class="wrap">
<!-- Progress indicator -->
<section class="plain">
<ol class="steps" id="step-indicator" style="margin:0 0 2rem">
<li data-step="1"><b>Upload</b> <span id="s1-sub">the binary (up to 2 GiB — free below 100 MiB)</span></li>
<li data-step="2"><b>Describe</b> <span id="s2-sub">name, publisher name, category, description</span></li>
<li data-step="3"><b>Sign</b> <span id="s3-sub">with the BCH key for your publisher BNS name</span></li>
<li data-step="4"><b>Publish</b> <span id="s4-sub">POST to the directory</span></li>
</ol>
</section>
<!-- =============== STEP 1: upload =============== -->
<section id="step1">
<h2>1 · Upload the binary</h2>
<p class="lede">The server scans with ClamAV + VirusTotal (where configured),
computes the SHA-256, and attaches a signed scan report to your listing.
Only clean uploads can proceed.</p>
<article class="wiz-step" data-step="1" data-state="current" id="step1">
<button class="wiz-head" type="button" data-jump="1">
<span class="wiz-num">1</span>
<span class="wiz-title">Upload</span>
<span class="wiz-sub" id="s1-sub">the binary (up to 2 GiB — free below 100 MiB)</span>
<span class="wiz-chip wiz-state-current" id="s1-chip">current</span>
</button>
<div class="wiz-body">
<p class="lede">Drop a binary below, or pick a file. The server scans with
ClamAV + VirusTotal (where configured), computes the SHA-256, and
attaches a signed scan report to your listing. Only clean uploads can
proceed.</p>
<form id="upload-form" style="display:grid;gap:12px;max-width:560px;margin-top:1rem">
<input type="file" name="file" required
style="padding:12px;background:var(--panel);border:1px solid var(--line);border-radius:10px;color:var(--ink)">
<button class="btn acid" type="submit">Upload &amp; scan</button>
<div id="upload-status" style="color:var(--mut);font-size:14px;min-height:1.4em"></div>
</form>
<label class="drop-zone" id="drop-zone">
<input type="file" name="file" id="file-input">
<div class="drop-icon">📁</div>
<div class="drop-main"><b>Drop a file here</b>, or <span class="drop-link">pick one</span></div>
<small class="drop-small">Any format. 2 GiB cap. Free below 100 MiB, then $1 per 100 MiB.</small>
</label>
<div id="upload-result" class="card" style="display:none;margin-top:1.2rem">
<h3 style="margin:0 0 .4rem">Upload accepted</h3>
<p style="margin:0;color:var(--mut);font-size:13.5px">
<b style="color:var(--ink)" id="u-name"></b> ·
<span id="u-size"></span> ·
<code class="inline" id="u-hash"></code>
</p>
<p style="margin:.5rem 0 0" id="u-scan"></p>
<p style="margin:.5rem 0 0" id="u-price"></p>
<button class="btn acid" type="button" id="to-step2" style="margin-top:1rem">Continue → describe your listing</button>
<div id="upload-status" style="color:var(--mut);font-size:14px;min-height:1.4em;margin-top:1rem"></div>
<div id="upload-result" class="card" style="display:none;margin-top:1rem">
<h3 style="margin:0 0 .4rem">Upload accepted</h3>
<p style="margin:0;color:var(--mut);font-size:13.5px">
<b style="color:var(--ink)" id="u-name"></b> ·
<span id="u-size"></span> ·
<code class="inline" id="u-hash"></code>
</p>
<p style="margin:.5rem 0 0" id="u-scan"></p>
<p style="margin:.5rem 0 0" id="u-price"></p>
<button class="btn acid" type="button" id="to-step2" style="margin-top:1rem">Continue → describe your listing</button>
</div>
</div>
</section>
</article>
<!-- =============== STEP 2: describe =============== -->
<section id="step2" style="display:none">
<h2>2 · Describe your listing</h2>
<p class="lede">Every field is signed in step 3, so take a minute — editing
the signed payload is a new signature, not a quick fix.</p>
<article class="wiz-step" data-step="2" data-state="pending" id="step2">
<button class="wiz-head" type="button" data-jump="2">
<span class="wiz-num">2</span>
<span class="wiz-title">Describe</span>
<span class="wiz-sub" id="s2-sub">name, publisher name, category, description</span>
<span class="wiz-chip wiz-state-pending" id="s2-chip">pending</span>
</button>
<div class="wiz-body">
<p class="lede">Every field is signed in step 3, so take a minute — editing
the signed payload is a new signature, not a quick fix.</p>
<form id="manifest-form" style="display:grid;gap:14px;max-width:620px;margin-top:1rem">
<form id="manifest-form" style="display:grid;gap:14px;max-width:620px;margin-top:1rem">
<label>Publisher BNS name
<input name="publisher" required placeholder="example.x"
pattern="^[a-z0-9][a-z0-9-]{0,62}\\.[a-z0-9]{1,20}$"
@ -452,62 +512,76 @@ export function submit() {
<small style="color:var(--dim)">Shown at 80 px on the listing detail. Fallback: your category's icon.</small>
</label>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:.6rem">
<button class="btn ghost" type="button" id="back-to-1">← Back to upload</button>
<button class="btn acid" type="submit">Continue → sign</button>
</div>
</form>
</section>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:.6rem">
<button class="btn ghost" type="button" id="back-to-1">← Back to upload</button>
<button class="btn acid" type="submit">Continue → sign</button>
</div>
</form>
</div>
</article>
<!-- =============== STEP 3: sign =============== -->
<section id="step3" style="display:none">
<h2>3 · Sign with your BNS key</h2>
<p class="lede">The message below carries every field from step 2 in canonical
form. Sign it with the BCH key for the address that owns
<b style="color:var(--ink)" id="sign-publisher">your name</b> on chipnet.
Any BIP-137 wallet works (Electron Cash, Deviant, Cashonize, Theseus's Aegis).</p>
<article class="wiz-step" data-step="3" data-state="pending" id="step3">
<button class="wiz-head" type="button" data-jump="3">
<span class="wiz-num">3</span>
<span class="wiz-title">Sign</span>
<span class="wiz-sub" id="s3-sub">with the BCH key for your publisher BNS name</span>
<span class="wiz-chip wiz-state-pending" id="s3-chip">pending</span>
</button>
<div class="wiz-body">
<p class="lede">The message below carries every field from step 2 in canonical
form. Sign it with the BCH key for the address that owns
<b style="color:var(--ink)" id="sign-publisher">your name</b> on chipnet.
Any BIP-137 wallet works (Electron Cash, Deviant, Cashonize, Theseus's Aegis).</p>
<h3 style="margin-top:1.4rem">Message to sign</h3>
<pre id="sign-message" style="max-height:260px;overflow:auto;white-space:pre-wrap;word-break:break-all;font-size:12px"></pre>
<h3 style="margin-top:1.4rem">Message to sign</h3>
<pre id="sign-message" style="max-height:260px;overflow:auto;white-space:pre-wrap;word-break:break-all;font-size:12px"></pre>
<div id="theseus-sign" style="display:none;margin-top:1.2rem;padding:14px 18px;background:var(--panel);border:1px solid var(--line);border-left:3px solid var(--acid);border-radius:10px">
<b style="color:var(--ink)">Theseus wallet detected.</b>
<p style="margin:.3rem 0;color:var(--mut);font-size:14px">Aegis is injected on this page. Sign without pasting.</p>
<button class="btn acid" type="button" id="theseus-sign-btn">Sign with Theseus wallet</button>
<div id="theseus-sign" style="display:none;margin-top:1.2rem;padding:14px 18px;background:var(--panel2);border:1px solid var(--line);border-left:3px solid var(--acid);border-radius:10px">
<b style="color:var(--ink)">Theseus wallet detected.</b>
<p style="margin:.3rem 0;color:var(--mut);font-size:14px">Aegis is injected on this page. Sign without pasting.</p>
<button class="btn acid" type="button" id="theseus-sign-btn">Sign with Theseus wallet</button>
</div>
<div style="margin-top:1.2rem">
<h3 style="margin:0 0 .3rem">Or paste a BIP-137 signature</h3>
<p style="color:var(--mut);font-size:13.5px;margin:0 0 .5rem">Base64, 88 characters. From any wallet's "sign message" dialog.</p>
<textarea id="sig-paste" rows="3"
style="width:100%;padding:10px;background:var(--panel2);border:1px solid var(--line);border-radius:8px;color:var(--ink);font-family:ui-monospace,monospace;font-size:12px;resize:vertical"
placeholder="H1234abcd…"></textarea>
</div>
<div id="sign-status" style="color:var(--mut);font-size:14px;min-height:1.4em;margin-top:.8rem"></div>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:1rem">
<button class="btn ghost" type="button" id="back-to-2">← Back to details</button>
<button class="btn acid" type="button" id="to-step4" disabled>Continue → publish</button>
</div>
</div>
<div style="margin-top:1.2rem">
<h3 style="margin:0 0 .3rem">Or paste a BIP-137 signature</h3>
<p style="color:var(--mut);font-size:13.5px;margin:0 0 .5rem">Base64, 88 characters. From any wallet's "sign message" dialog.</p>
<textarea id="sig-paste" rows="3"
style="width:100%;padding:10px;background:var(--panel);border:1px solid var(--line);border-radius:8px;color:var(--ink);font-family:ui-monospace,monospace;font-size:12px;resize:vertical"
placeholder="H1234abcd…"></textarea>
</div>
<div id="sign-status" style="color:var(--mut);font-size:14px;min-height:1.4em;margin-top:.8rem"></div>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:1rem">
<button class="btn ghost" type="button" id="back-to-2">← Back to details</button>
<button class="btn acid" type="button" id="to-step4" disabled>Continue → publish</button>
</div>
</section>
</article>
<!-- =============== STEP 4: publish =============== -->
<section id="step4" style="display:none">
<h2>4 · Publish</h2>
<p class="lede">POSTs the signed listing to <code class="inline">/api/listing</code>.
The server re-verifies the signature against the on-chain owner of your BNS
name and stores the row. The listing is live on browse the moment the POST
returns 201.</p>
<article class="wiz-step" data-step="4" data-state="pending" id="step4">
<button class="wiz-head" type="button" data-jump="4">
<span class="wiz-num">4</span>
<span class="wiz-title">Publish</span>
<span class="wiz-sub" id="s4-sub">POST to the directory</span>
<span class="wiz-chip wiz-state-pending" id="s4-chip">pending</span>
</button>
<div class="wiz-body">
<p class="lede">POSTs the signed listing to <code class="inline">/api/listing</code>.
The server re-verifies the signature against the on-chain owner of your BNS
name and stores the row. The listing is live on browse the moment the POST
returns 201.</p>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:1rem">
<button class="btn ghost" type="button" id="back-to-3">← Back to sign</button>
<button class="btn acid" type="button" id="publish-btn">Publish now</button>
<button class="btn ghost" type="button" id="back-to-3">← Back to sign</button>
<button class="btn acid" type="button" id="publish-btn">Publish now</button>
</div>
<div id="publish-status" style="color:var(--mut);font-size:14px;min-height:1.4em;margin-top:1rem"></div>
<div id="publish-result" style="margin-top:1rem"></div>
</div>
<div id="publish-status" style="color:var(--mut);font-size:14px;min-height:1.4em;margin-top:1rem"></div>
<div id="publish-result" style="margin-top:1rem"></div>
</section>
</article>
<script>
(() => {
@ -518,18 +592,67 @@ export function submit() {
manifest: null, // from the form in step 2
message: null, // canonical message string in step 3
signature: null, // base64 BIP-137
priceUsd: null, // size-tiered fee computed in step 1
};
const $ = (id) => document.getElementById(id);
function show(step) {
for (const n of [1,2,3,4]) $('step'+n).style.display = n === step ? '' : 'none';
// Progress indicator highlights the current step.
document.querySelectorAll('#step-indicator li').forEach((li) => {
const s = Number(li.dataset.step);
li.style.opacity = s === step ? '1' : s < step ? '.5' : '.3';
});
window.scrollTo({top:0, behavior:'smooth'});
const SUBS = {
1: 'the binary (up to 2 GiB — free below 100 MiB)',
2: 'name, publisher name, category, description',
3: 'with the BCH key for your publisher BNS name',
4: 'POST to the directory',
};
// A step is reachable only if every earlier step is complete OR it's the
// current one — stops a publisher from clicking "Publish" before they have
// signed anything, and leaves "come back and edit step 2" available.
function canReach(n) {
for (let i = 1; i < n; i++) {
const el = $('step' + i);
if (el.dataset.state !== 'complete' && el.dataset.state !== 'current') return false;
}
return true;
}
function setState(n, state) {
const el = $('step' + n);
el.dataset.state = state;
const chip = $('s' + n + '-chip');
chip.textContent = state;
chip.className = 'wiz-chip wiz-state-' + state;
}
function show(step) {
for (const n of [1,2,3,4]) {
const el = $('step' + n);
const s = el.dataset.state;
if (n === step) setState(n, 'current');
else if (s === 'current') setState(n, canReach(n) && hasStepData(n) ? 'complete' : 'pending');
}
// Scroll the active step card into view, not the top of the page.
$('step' + step).scrollIntoView({behavior:'smooth', block:'start'});
}
// "Does the state have what step N would have produced?" — used when
// leaving a step to decide if it should be marked complete or back to
// pending (e.g., clicking back to step 2 before editing is still complete).
function hasStepData(n) {
if (n === 1) return !!W.upload;
if (n === 2) return !!W.manifest;
if (n === 3) return !!W.signature;
if (n === 4) return false; // publish never "completes" in-session
return false;
}
// Clickable headers — jump to any reachable step.
document.querySelectorAll('[data-jump]').forEach((btn) => {
btn.addEventListener('click', () => {
const n = Number(btn.dataset.jump);
if (!canReach(n)) return;
show(n);
});
});
show(1);
function fmtBytes(n) {
@ -549,14 +672,32 @@ export function submit() {
return '{' + k.map((key) => JSON.stringify(key) + ':' + canonicalJson(v[key])).join(',') + '}';
}
// ===== STEP 1: upload =====
$('upload-form').addEventListener('submit', async (ev) => {
// ===== STEP 1: upload (drag-drop zone + file picker) =====
const dz = $('drop-zone');
const fi = $('file-input');
dz.addEventListener('click', (ev) => {
// Clicking the hidden <input> directly works; stop click bubbling from
// the inner link span so we don't open the picker twice.
if (ev.target === fi) return;
fi.click();
});
['dragover','dragenter'].forEach((e) => dz.addEventListener(e, (ev) => {
ev.preventDefault(); dz.classList.add('dragover');
}));
['dragleave','drop'].forEach((e) => dz.addEventListener(e, () => dz.classList.remove('dragover')));
dz.addEventListener('drop', (ev) => {
ev.preventDefault();
const file = ev.target.file.files[0];
if (!file) return;
const file = ev.dataTransfer.files[0];
if (file) startUpload(file);
});
fi.addEventListener('change', () => fi.files[0] && startUpload(fi.files[0]));
async function startUpload(file) {
W.file = file;
const status = $('upload-status');
$('upload-result').style.display = 'none';
status.textContent = \`Uploading \${file.name} (\${fmtBytes(file.size)}) and scanning…\`;
const body = new FormData();
body.append('file', file);
const started = performance.now();
@ -596,17 +737,14 @@ export function submit() {
: '<span class="pill p-upcoming">listing fee: $' + priceUsd + ' USD (settled in BCH at the step-4 oracle rate)</span>';
$('upload-result').style.display = 'block';
status.textContent = '';
});
// Mark step 1 complete + update the sub-text in its collapsed form so a
// publisher who scrolls back sees the actual filename not the hint.
$('s1-sub').textContent = json.upload.name + ' · ' + fmtBytes(json.upload.size) +
(priceUsd === 0 ? ' · free tier' : ' · $' + priceUsd + ' listing fee');
}
// ===== STEP 1 → 2 =====
$('to-step2').addEventListener('click', () => {
// Prefill name + download_url + hash preview.
const f = $('manifest-form');
if (W.upload) {
// leave the fields the user needs to fill blank; show the hash we'll use
}
show(2);
});
$('to-step2').addEventListener('click', () => show(2));
// ===== STEP 2: describe =====
$('back-to-1').addEventListener('click', () => show(1));
@ -654,6 +792,9 @@ export function submit() {
$('sig-paste').value = '';
$('sign-status').textContent = '';
$('to-step4').disabled = true;
// Collapsed-step summary for step 2.
$('s2-sub').textContent = W.manifest.name + ' by ' + W.manifest.publisher +
' · ' + W.manifest.category + ' · v' + W.manifest.version;
show(3);
});
@ -673,10 +814,7 @@ export function submit() {
// Different wallets return different shapes; normalise to base64 string.
const base64 = typeof sig === 'string' ? sig : sig?.signature || sig?.sig || '';
if (!base64) throw new Error('wallet returned no signature');
W.signature = base64;
$('sig-paste').value = base64;
status.innerHTML = '✓ Signed. <b>' + base64.length + ' chars.</b> Continue to publish.';
$('to-step4').disabled = false;
acceptSignature(base64, 'Theseus wallet');
} catch (err) {
status.innerHTML = '⚠️ Theseus-wallet sign failed — ' + String(err?.message || err) +
'. Paste a signature from another wallet below.';
@ -686,9 +824,7 @@ export function submit() {
$('sig-paste').addEventListener('input', (ev) => {
const v = ev.target.value.trim();
if (v.length === 88 || v.length === 86 || v.length === 87) {
W.signature = v;
$('sign-status').innerHTML = '✓ ' + v.length + '-char signature captured. Continue to publish.';
$('to-step4').disabled = false;
acceptSignature(v, 'paste');
} else {
W.signature = null;
$('to-step4').disabled = true;
@ -696,6 +832,14 @@ export function submit() {
}
});
function acceptSignature(base64, how) {
W.signature = base64;
$('sig-paste').value = base64;
$('sign-status').innerHTML = '✓ Signed via ' + how + ' (' + base64.length + ' chars). Continue to publish.';
$('to-step4').disabled = false;
$('s3-sub').textContent = 'signed · ' + base64.slice(0, 10) + '…';
}
$('to-step4').addEventListener('click', () => show(4));
// ===== STEP 4: publish =====