refactor(hephaestus): wallet picker is an accordion, not a page swap

Clicking Create / Import / WizardConnect now expands the flow *inside* that
option (native <details>/<summary>) instead of hiding the picker and rendering
a separate view. Opening one option auto-closes the others. Chevron flips and
border turns accent-orange on the open card. "Back" buttons removed — closing
is a click on the same summary.

- wallet.js: renderTabs() emits three <details class="wallet-opt">; the
  per-option flow content is populated lazily on first `toggle` and lives
  inside `.wallet-flow` (no more global #panel). Accordion behavior is
  enforced by closing sibling <details> on toggle.
- login.css: .wallet-opt reworked as details-summary card; chevron rotates
  on [open]; .wallet-flow is the divider-topped body inside each card.
This commit is contained in:
Local Dev 2026-09-01 23:20:31 +02:00
parent e67943ed00
commit 34b58b8637
2 changed files with 92 additions and 97 deletions

View file

@ -36,44 +36,45 @@ h2 { font-size: 17px; margin: 0 0 6px; }
p.lede { color: var(--muted); margin: 0 0 20px; font-size: 14px; }
p.sub { color: var(--muted); margin: 0 0 12px; font-size: 13.5px; }
/* --- Wallet picker (three big options — Create / Import / Connect) --- */
/* --- Wallet picker: accordion of three options (Create / Import / Connect) --- */
.wallet-picker { display: grid; gap: 10px; margin: 8px 0 18px; }
.wallet-opt {
display: grid; grid-template-columns: 40px 1fr; gap: 14px; align-items: start;
padding: 14px 16px; background: var(--bg); border: 1px solid var(--border);
border-radius: 10px; cursor: pointer; text-align: left; font: inherit; color: var(--text);
transition: border-color .1s, background .1s;
details.wallet-opt {
background: var(--bg); border: 1px solid var(--border); border-radius: 10px;
overflow: hidden; transition: border-color .1s, background .1s;
}
.wallet-opt:hover { border-color: var(--accent); background: var(--card); }
details.wallet-opt:hover { border-color: var(--accent); }
details.wallet-opt[open] { border-color: var(--accent); background: var(--card); }
details.wallet-opt > summary {
display: grid; grid-template-columns: 40px 1fr auto; gap: 14px; align-items: start;
padding: 14px 16px; cursor: pointer; list-style: none; user-select: none;
}
details.wallet-opt > summary::-webkit-details-marker { display: none; }
details.wallet-opt > summary::marker { content: ""; }
.wallet-opt .ico { font-size: 24px; line-height: 1; padding-top: 2px; }
.wallet-opt .body { display: flex; flex-direction: column; gap: 4px; }
.wallet-opt .body { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.wallet-opt b { color: var(--text); font-size: 14.5px; font-weight: 600;
display: inline-flex; align-items: center; gap: 8px; }
display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.wallet-opt .sub { color: var(--muted); font-size: 13px; line-height: 1.5; }
.wallet-opt .pill {
display: inline-block; font-size: 10.5px; padding: 1px 8px; border-radius: 999px;
background: rgba(255,140,0,.14); color: var(--accent); font-weight: 500;
letter-spacing: 0.02em; text-transform: lowercase;
}
.wallet-opt .chev {
color: var(--muted); font-size: 14px; padding-top: 4px;
transition: transform .15s ease;
}
details.wallet-opt[open] > summary .chev { transform: rotate(180deg); color: var(--accent); }
p.fine {
color: var(--muted); font-size: 12.5px; margin: 4px 0 0; text-align: center;
}
p.fine a { color: var(--muted); }
p.fine a:hover { color: var(--text); }
/* --- Selected flow panel (Create/Import/Connect) --- */
.flow { display: flex; flex-direction: column; gap: 4px; }
.flow > label { margin-top: 12px; }
.flow .row {
display: flex; gap: 10px; margin-top: 18px; align-items: center;
}
.flow .row .primary { flex: 1; margin-top: 0; }
.flow .row .ghost {
background: transparent; border: 1px solid var(--border); color: var(--muted);
padding: 10px 14px; border-radius: 8px; font: inherit; font-size: 13.5px;
cursor: pointer;
}
.flow .row .ghost:hover { color: var(--text); border-color: var(--muted); }
/* --- Expanded flow inside a wallet-opt --- */
.wallet-flow { padding: 0 16px 16px 16px; border-top: 1px solid var(--border); }
.wallet-flow .flow { display: flex; flex-direction: column; gap: 4px; padding-top: 12px; }
.wallet-flow .flow > label { margin-top: 12px; }
/* --- WizardConnect coming-soon panel --- */
.wc-supported { display: grid; gap: 8px; margin: 8px 0 14px; }

View file

@ -159,115 +159,109 @@ function renderTabs() {
<h1>Sign in with your <span class="acid">Bitcoin Cash</span> wallet</h1>
<p class="lede">No email. No password reset. Your wallet is your identity.</p>
<div class="wallet-picker" id="picker">
<button class="wallet-opt" data-choice="create">
<span class="ico">✨</span>
<span class="body">
<b>Create a new wallet<span class="pill">easiest</span></b>
<span class="sub">Made here in your browser. You get a recovery phrase to write down — it is the only key.</span>
</span>
</button>
<button class="wallet-opt" data-choice="import">
<span class="ico">🔑</span>
<span class="body">
<b>Import / add a wallet</b>
<span class="sub">Restore a wallet you already have from its 12- or 24-word recovery phrase.</span>
</span>
</button>
<button class="wallet-opt" data-choice="connect">
<span class="ico">🪄</span>
<span class="body">
<b>Connect a wallet — WizardConnect<span class="pill">most private</span></b>
<span class="sub">Cashonize 0.9+ or Paytaca. Your keys never leave your wallet.</span>
</span>
</button>
<details class="wallet-opt" data-choice="create">
<summary>
<span class="ico">✨</span>
<span class="body">
<b>Create a new wallet<span class="pill">easiest</span></b>
<span class="sub">Made here in your browser. You get a recovery phrase to write down — it is the only key.</span>
</span>
<span class="chev" aria-hidden="true">▾</span>
</summary>
<div class="wallet-flow"></div>
</details>
<details class="wallet-opt" data-choice="import">
<summary>
<span class="ico">🔑</span>
<span class="body">
<b>Import / add a wallet</b>
<span class="sub">Restore a wallet you already have from its 12- or 24-word recovery phrase.</span>
</span>
<span class="chev" aria-hidden="true">▾</span>
</summary>
<div class="wallet-flow"></div>
</details>
<details class="wallet-opt" data-choice="connect">
<summary>
<span class="ico">🪄</span>
<span class="body">
<b>Connect a wallet — WizardConnect<span class="pill">most private</span></b>
<span class="sub">Cashonize 0.9+ or Paytaca. Your keys never leave your wallet.</span>
</span>
<span class="chev" aria-hidden="true">▾</span>
</summary>
<div class="wallet-flow"></div>
</details>
</div>
<p class="fine">Prefer old-school? <a href="/user/login?password=1">Sign in with username &amp; password →</a></p>
<div id="panel"></div>
</div>
`));
app.querySelectorAll(".wallet-opt").forEach((b) => {
b.addEventListener("click", () => {
// Hide the picker while a specific panel is open
app.querySelector("#picker").classList.add("hidden");
app.querySelector(".fine")?.classList.add("hidden");
renderPanel(b.dataset.choice);
app.querySelectorAll("details.wallet-opt").forEach((d) => {
d.addEventListener("toggle", () => {
if (!d.open) return;
// Accordion: close siblings
app.querySelectorAll("details.wallet-opt").forEach((other) => {
if (other !== d) other.open = false;
});
// Lazily populate this option's flow content the first time it opens
const flow = d.querySelector(".wallet-flow");
if (flow && !flow.dataset.ready) {
populateFlow(d.dataset.choice, flow);
flow.dataset.ready = "1";
}
});
});
}
function backToPicker() {
app.querySelector("#picker")?.classList.remove("hidden");
app.querySelector(".fine")?.classList.remove("hidden");
const panel = document.getElementById("panel");
if (panel) panel.innerHTML = "";
}
function renderPanel(kind) {
const panel = document.getElementById("panel");
panel.innerHTML = "";
function populateFlow(kind, flow) {
if (kind === "create") {
const mnemonic = generateBip39Mnemonic();
panel.appendChild(el(`
flow.appendChild(el(`
<div class="flow">
<h2>Your new recovery phrase</h2>
<p class="sub">Write it down. This is the only way to recover your account — we can never help you reset it.</p>
<p class="sub"><b>Your new recovery phrase.</b> Write it down. This is the only way to recover your account — we can never help you reset it.</p>
<div class="mnemonic-box">${mnemonic}</div>
<label>Set a passphrase to encrypt this wallet in your browser</label>
<input type="password" id="pw" autocomplete="new-password" placeholder="min 8 chars">
<input type="password" class="pw" autocomplete="new-password" placeholder="min 8 chars">
<p class="small">The passphrase never leaves your device. Lose both phrase and passphrase = account is gone.</p>
<div class="row">
<button class="primary" id="go">Create wallet &amp; sign in</button>
<button class="ghost" id="back">← Back</button>
</div>
<p class="warn hidden" id="err"></p>
<button class="primary go">Create wallet &amp; sign in</button>
<p class="warn hidden err"></p>
</div>
`));
panel.querySelector("#back").addEventListener("click", backToPicker);
panel.querySelector("#go").addEventListener("click", async () => {
const pw = panel.querySelector("#pw").value;
if (pw.length < 8) return showErr(panel, "passphrase must be at least 8 chars");
await onboardAndLogin(mnemonic, pw, panel);
flow.querySelector(".go").addEventListener("click", async () => {
const pw = flow.querySelector(".pw").value;
if (pw.length < 8) return showErr(flow, "passphrase must be at least 8 chars");
await onboardAndLogin(mnemonic, pw, flow);
});
} else if (kind === "import") {
panel.appendChild(el(`
flow.appendChild(el(`
<div class="flow">
<h2>Import a wallet</h2>
<p class="sub">Paste your BIP-39 recovery phrase (12 or 24 words). Same phrase = same account.</p>
<textarea id="mn" spellcheck="false" autocomplete="off" placeholder="word word word ..."></textarea>
<textarea class="mn" spellcheck="false" autocomplete="off" placeholder="word word word ..."></textarea>
<label>Set a passphrase to encrypt this wallet in your browser</label>
<input type="password" id="pw" autocomplete="new-password" placeholder="min 8 chars">
<div class="row">
<button class="primary" id="go">Import &amp; sign in</button>
<button class="ghost" id="back">← Back</button>
</div>
<p class="warn hidden" id="err"></p>
<input type="password" class="pw" autocomplete="new-password" placeholder="min 8 chars">
<button class="primary go">Import &amp; sign in</button>
<p class="warn hidden err"></p>
</div>
`));
panel.querySelector("#back").addEventListener("click", backToPicker);
panel.querySelector("#go").addEventListener("click", async () => {
const mn = panel.querySelector("#mn").value.trim().replace(/\s+/g, " ");
const pw = panel.querySelector("#pw").value;
if (pw.length < 8) return showErr(panel, "passphrase must be at least 8 chars");
flow.querySelector(".go").addEventListener("click", async () => {
const mn = flow.querySelector(".mn").value.trim().replace(/\s+/g, " ");
const pw = flow.querySelector(".pw").value;
if (pw.length < 8) return showErr(flow, "passphrase must be at least 8 chars");
try { await keypairFromMnemonic(mn); }
catch { return showErr(panel, "invalid recovery phrase"); }
await onboardAndLogin(mn, pw, panel);
catch { return showErr(flow, "invalid recovery phrase"); }
await onboardAndLogin(mn, pw, flow);
});
} else if (kind === "connect") {
panel.appendChild(el(`
flow.appendChild(el(`
<div class="flow">
<h2>Connect a wallet</h2>
<p class="sub">Sign the challenge with an external BCH wallet — your keys never leave it. Encrypted end-to-end over Nostr; the relay only sees ciphertext.</p>
<div class="wc-supported">
<div class="wc-wallet"><b>Cashonize</b><span>browser extension · v0.9+</span><a href="https://cashonize.com/" target="_blank" rel="noopener">install →</a></div>
<div class="wc-wallet"><b>Paytaca</b><span>mobile · iOS + Android</span><a href="https://paytaca.com/" target="_blank" rel="noopener">install →</a></div>
</div>
<p class="note-soon"><b>Coming soon.</b> WizardConnect for message-signing is being wired in — for now, use Create or Import above (or the button below to jump back to the picker).</p>
<div class="row">
<button class="ghost" id="back">← Back to picker</button>
</div>
<p class="note-soon"><b>Coming soon.</b> WizardConnect for message-signing is being wired in — for now, use Create or Import above.</p>
</div>
`));
panel.querySelector("#back").addEventListener("click", backToPicker);
}
}