aboutsummaryrefslogtreecommitdiffstats
path: root/src/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/app.js')
-rw-r--r--src/app.js291
1 files changed, 210 insertions, 81 deletions
diff --git a/src/app.js b/src/app.js
index 9ce7190..23a1d42 100644
--- a/src/app.js
+++ b/src/app.js
@@ -77,7 +77,7 @@ const ui = {
confirmClear: false, // "Clear all" confirmation shown
editId: null, // account being renamed
editLabel: '',
- createStep: 'gen', // 'gen' | 'confirm'
+ createStep: 'intro', // 'intro' (the heads-up) | 'gen' (the words) | 'confirm' (the one-word check)
draftMnemonic: '',
confirm: [], // [{ index, value }]
confirmPass: '', // re-entered passphrase (confirm field, shown once one is typed)
@@ -1338,19 +1338,36 @@ function unitTag(cls = '') {
// ================================================================ UNLOCK
// The create/import card on its own — the unlock screen wraps it in the
// classic chrome, the onboarding wizard's "Get started" step shows it bare.
+// The front door's seed pages (first run, or "Sign into another account"):
+// no tabs, and the page's own Back steps through the create flow.
+function seedPages() {
+ return ui.screen === 'unlock' && !!((ui.onb && ui.onb.step === 'seed') || (ui.fromWallet && ui.addFlow));
+}
+// Back within the create flow: the check → the passphrase → the words → the
+// heads-up. False when there's no earlier step (the caller leaves the flow).
+const CREATE_PREV = { gen: 'intro', pass: 'gen', confirm: 'pass' };
+function createStepBack() {
+ if (ui.unlockTab !== 'create' || ui.entropyPage || !CREATE_PREV[ui.createStep]) return false;
+ ui.createStep = CREATE_PREV[ui.createStep];
+ ui.unlockError = '';
+ render();
+ return true;
+}
+
function unlockCard() {
// The entropy page takes the card over entirely — seed words, tabs and
// other controls step aside until Submit or Back.
if (ui.entropyPage) return h('div', { class: 'card col' }, entropyPage());
// Inside the first-run wizard the words are already on screen: no tabs,
// just a line underneath for whoever brought their own seed (and one back).
- const wizard = ui.screen === 'unlock' && ui.onb && ui.onb.step === 'seed';
- const swap = wizard ? h('button', {
+ const wizard = seedPages();
+ // only before the words are up: once they're shown, it was already offered
+ const swap = wizard && (ui.unlockTab === 'import' || ui.createStep === 'intro') ? h('button', {
class: 'linklike small', style: 'align-self:center',
onClick: () => {
clearSeedDrafts(); ui.unlockError = '';
if (ui.unlockTab === 'create') ui.unlockTab = 'import';
- else { ui.unlockTab = 'create'; ui.createStep = 'gen'; ui.draftRandom = ui.draftMnemonic = newMnemonic(); }
+ else { ui.unlockTab = 'create'; ui.createStep = 'intro'; ui.draftRandom = ui.draftMnemonic = newMnemonic(); }
render();
},
}, ui.unlockTab === 'create' ? t('onbHaveSeed') : t('onbCreateInstead')) : null;
@@ -1369,7 +1386,77 @@ function unlockCard() {
);
}
+// "Sign in to another account", laid out as the front door is (Adam,
+// 2026-11-07): two choices, then the sign-in list with the seed phrase
+// first. Everything here adds an account beside the open ones; Back returns
+// to the wallet.
+function addAccountScreen() {
+ const page = (kids) => h('div', { class: 'col onb', style: 'gap:20px' }, ...kids);
+ const title = (txt) => h('h2', { class: 'onb-title' }, txt);
+ const toWallet = () => {
+ ui.fromWallet = false; ui.addFlow = false; ui.addStep = null; ui.nostrLoginOpen = false;
+ ui.screen = 'wallet'; ui.unlockError = ''; ui.nostrLoginError = ''; render();
+ };
+ if (ui.addStep === 'doors') {
+ if (ui.nostrLoginOpen) {
+ return page([
+ h('div', { class: 'onb-mark', html: NOSTR_MARK }),
+ title(t('onbSigninTitle')),
+ featureHook('unlockExtra') || h('div', { class: 'notice err' }, 'nostr login unavailable'),
+ h('button', { class: 'linklike small', onClick: () => { ui.nostrLoginOpen = false; ui.nostrLoginError = ''; render(); } }, t('back')),
+ ]);
+ }
+ return page([
+ h('div', { class: 'onb-hero' }, brandHeader(false)),
+ title(t('onbSigninTitle')),
+ h('div', { class: 'col', style: 'gap:10px' },
+ h('button', { id: 'add-restore', class: 'btn-block', style: 'padding:14px', onClick: () => {
+ clearSeedDrafts();
+ ui.unlockTab = 'import'; ui.unlockError = ''; ui.addStep = null;
+ render();
+ } }, t('onbRestoreDoor')),
+ featureHook('frontDoorSignin', { short: true })),
+ h('button', { class: 'linklike small', onClick: () => { ui.nostrLoginError = ''; ui.addStep = 'welcome'; render(); } }, t('back')),
+ ]);
+ }
+ return page([
+ h('div', { class: 'onb-hero' }, brandHeader(false)),
+ title(t('addAccountTitle')),
+ h('div', { class: 'col', style: 'gap:10px' },
+ h('button', {
+ id: 'add-create',
+ class: 'btn-primary btn-block', style: 'font-size:17px;padding:14px', onClick: () => {
+ clearSeedDrafts();
+ ui.unlockTab = 'create'; ui.unlockError = ''; ui.createStep = 'intro'; ui.confirm = [];
+ ui.draftRandom = ui.draftMnemonic = newMnemonic();
+ ui.addStep = null;
+ render();
+ },
+ }, t('onbStart')),
+ h('button', {
+ id: 'add-signin',
+ class: 'btn-block', style: 'font-size:17px;padding:14px',
+ onClick: () => { ui.nostrLoginError = ''; ui.addStep = 'doors'; render(); },
+ }, t('onbSignInExisting'))),
+ accounts.length ? h('button', { class: 'linklike small', onClick: toWallet }, t('back')) : null,
+ ]);
+}
+
function unlockScreen() {
+ if (ui.fromWallet && ui.addFlow) {
+ if (ui.addStep && !ui.entropyPage) return addAccountScreen();
+ // a choice made there: the same seed pages as the front door, whose
+ // Back steps back to where it was made
+ return h('div', { class: 'col onb', style: 'gap:20px' },
+ h('h2', { class: 'onb-title' }, t(ui.unlockTab === 'import' ? 'onbRestoreTitle' : 'onbSeedTitle')),
+ unlockCard(),
+ ui.entropyPage ? null : h('button', { class: 'linklike small', onClick: () => {
+ if (createStepBack()) return;
+ const from = ui.unlockTab === 'import' ? 'doors' : 'welcome';
+ clearSeedDrafts(); ui.draftMnemonic = ''; ui.createStep = 'intro'; ui.confirm = []; ui.unlockError = '';
+ ui.addStep = from; render();
+ } }, t('back')));
+ }
return h(
'div',
{ class: 'col', style: 'gap:16px' },
@@ -1476,6 +1563,27 @@ function tabBtn(label, active, onClick) {
}
function createPane() {
+ // A heads-up before the words appear: they are secret, and the screen is
+ // about to show them (Adam, 2026-11-07). No warning icon — a plain note.
+ if (ui.createStep === 'intro' || !ui.createStep) {
+ const n = ui.draftMnemonic ? ui.draftMnemonic.split(' ').length : 12;
+ return h(
+ 'div',
+ { class: 'col seed-intro' },
+ h('p', { style: 'margin:0' }, t('seedIntroBody', { n })),
+ h('p', { class: 'muted', style: 'margin:0' }, t('seedIntroPrivate')),
+ h('button', {
+ id: 'seed-intro-next',
+ class: 'btn-primary btn-block', style: 'padding:14px',
+ onClick: () => {
+ if (!ui.draftMnemonic) ui.draftRandom = ui.draftMnemonic = newMnemonic();
+ ui.unlockError = '';
+ ui.createStep = 'gen';
+ render();
+ },
+ }, t('seedIntroShow')),
+ );
+ }
if (ui.createStep === 'gen') {
// The wizard shows words, never a Generate button: a refresh restores
// the step but not the draft (drafts live in memory only), so draw a
@@ -1513,74 +1621,78 @@ function createPane() {
h('button', { class: 'btn-block', style: 'display:flex;align-items:center;justify-content:center;gap:8px', onClick: () => copy(ui.draftMnemonic) },
h('span', { style: 'display:flex', html: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>' }),
t('copy')),
- optionsPanel(),
h(
'button',
{
+ id: 'seed-next',
class: 'btn-primary btn-block',
- onClick: () => {
- // catch a mistyped passphrase here, while the field to fix it is
- // still on screen — the verify step only samples the words
- if (ui.passphrase && ui.confirmPass !== ui.passphrase) {
- ui.unlockError = t('passphraseMismatch'); render(); return;
- }
- ui.confirm = pickConfirm(words);
- ui.unlockError = '';
- ui.createStep = 'confirm';
- render();
- },
+ onClick: () => { ui.unlockError = ''; ui.createStep = 'pass'; render(); },
},
- t('verifyBackup')
- ),
- h(
- 'button',
- { class: 'btn-block', onClick: () => openWallet(ui.draftMnemonic, { generated: true }) },
- t('skipVerification')
+ t('next')
)
);
}
- // confirm step (optional — reachable via "Verify backup")
+ // The passphrase, on a page of its own (Adam, 2026-11-07): what it is,
+ // that it's optional but recommended, and the field — a small "Add
+ // passphrase" button under the words was easy to miss.
+ if (ui.createStep === 'pass') {
+ const words = ui.draftMnemonic.split(' ');
+ const next = () => {
+ // a typo here IS a different wallet: caught while the fields are up
+ if (ui.passphrase && ui.confirmPass !== ui.passphrase) {
+ ui.unlockError = t('passphraseMismatch'); render(); return;
+ }
+ ui.confirm = pickConfirm(words);
+ ui.unlockError = '';
+ ui.createStep = 'confirm';
+ render();
+ };
+ return h(
+ 'div',
+ { class: 'col' },
+ h('h3', { style: 'margin:0' }, t('passStepTitle')),
+ h('p', { style: 'margin:0' }, t('passStepBody', { n: words.length })),
+ h('p', { class: 'muted', style: 'margin:0' }, t('passStepNote')),
+ ...passFields(),
+ h('button', { id: 'pass-next', class: 'btn-primary btn-block', onClick: next }, t(ui.passphrase ? 'next' : 'passStepNone')),
+ );
+ }
+ // The check: which word is #5? One tap from a few of the phrase's own
+ // words, or Skip (Adam, 2026-11-07) — no typing.
+ const q = ui.confirm;
+ if (!q || !q.options) { ui.createStep = 'pass'; return createPane(); }
return h(
'div',
{ class: 'col' },
- h('p', { class: 'muted' }, t('confirmBackupIntro')),
- ...ui.confirm.map((c, i) =>
- h(
- 'label',
- { class: 'field' },
- h('span', { class: 'lab' }, t('wordN', { n: c.index + 1 })),
- h('input', {
- type: 'text',
- class: 'mono-input',
- autocapitalize: 'none',
- autocomplete: 'off',
- spellcheck: 'false',
- value: c.value,
- onInput: (e) => (ui.confirm[i].value = e.target.value.trim()),
- })
- )
- ),
- h('div', { class: 'row gap6' },
- h('button', { class: 'btn-ghost', onClick: () => { ui.createStep = 'gen'; render(); } }, t('back')),
+ h('p', { style: 'margin:0' }, t('quizPrompt', { n: q.index + 1 })),
+ h('div', { class: 'quiz-opts' }, q.options.map((w) =>
h('button', {
- class: 'btn-primary grow',
+ type: 'button',
+ class: 'quiz-opt' + (q.wrong && q.wrong.includes(w) ? ' wrong' : ''),
onClick: () => {
const words = ui.draftMnemonic.split(' ');
- const ok = ui.confirm.every((c) => c.value.toLowerCase() === words[c.index]);
- if (!ok) { ui.unlockError = t('wordsMismatch'); render(); return; }
- openWallet(ui.draftMnemonic, { generated: true });
+ if (w === words[q.index]) { ui.unlockError = ''; openWallet(ui.draftMnemonic, { generated: true }); return; }
+ (q.wrong ||= []).includes(w) || q.wrong.push(w);
+ render();
},
- }, t('openWallet'))
- ),
- h('button', { class: 'btn-block', onClick: () => openWallet(ui.draftMnemonic, { generated: true }) }, t('skipVerification'))
+ }, w))),
+ q.wrong && q.wrong.length ? h('div', { class: 'notice err' }, t('quizWrong')) : null,
+ h('button', { id: 'seed-skip', class: 'btn-ghost btn-block', onClick: () => { ui.unlockError = ''; openWallet(ui.draftMnemonic, { generated: true }); } }, t('skip')),
+ // outside the seed pages (no Back of their own), a way back to the words
+ seedPages() ? null : h('button', { class: 'linklike small', style: 'align-self:center', onClick: () => { ui.createStep = 'pass'; ui.unlockError = ''; render(); } }, t('back')),
);
}
-function pickConfirm(words) {
- const idx = new Set();
- while (idx.size < 3) idx.add(Math.floor(Math.random() * words.length));
- return [...idx].sort((a, b) => a - b).map((index) => ({ index, value: '' }));
+// The 5th word and three others from the same phrase, shuffled: knowing the
+// words isn't enough, you need them in order.
+function pickConfirm(words, index = Math.min(4, words.length - 1)) {
+ const right = words[index];
+ const others = [...new Set(words.filter((w) => w !== right))];
+ for (let i = others.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [others[i], others[j]] = [others[j], others[i]]; }
+ const options = [right, ...others.slice(0, 3)];
+ for (let i = options.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [options[i], options[j]] = [options[j], options[i]]; }
+ return { index, options, wrong: [] };
}
// The BYO-entropy page, shared by Create (re-derives the draft phrase) and
@@ -1674,22 +1786,28 @@ function optionsPanel() {
// already typed keeps it open.
if (!ui.passOpen && !ui.passphrase) {
return [h('button', {
- class: 'btn-ghost btn-sm', type: 'button', style: 'align-self:flex-start',
+ class: 'btn-block', type: 'button',
onClick: (e) => { e.preventDefault(); ui.passOpen = true; render(); },
}, t('addPassphrase'))];
}
+ return passFields({ label: true });
+}
+
+// The passphrase field (+ its confirmation once something is typed). The
+// create flow's own page explains it above; the import pane labels it here.
+function passFields({ label = false } = {}) {
const pass = h(
'label',
{ class: 'field' },
h('span', { class: 'lab' },
- t('passphraseOptional'),
+ t(label ? 'passphraseOptional' : 'passphrase'),
// People assume a password logs in or encrypts; a BIP39 passphrase does
// neither, and getting that wrong loses money — worth a sentence here.
h('button', {
class: 'linklike small', type: 'button', style: 'margin-left:8px',
onClick: (e) => { e.preventDefault(); ui.passInfoOpen = !ui.passInfoOpen; render(); },
}, t('passWhatsThis'))),
- ui.passInfoOpen ? h('p', { class: 'small muted', style: 'margin:0 0 6px' }, t('passInfo')) : h('p', { class: 'small muted', style: 'margin:0 0 6px' }, t('passOptionalHint')),
+ ui.passInfoOpen ? h('p', { class: 'small muted', style: 'margin:0 0 6px' }, t('passInfo')) : label ? h('p', { class: 'small muted', style: 'margin:0 0 6px' }, t('passOptionalHint')) : null,
h(
'div',
{ class: 'input-group' },
@@ -1718,7 +1836,7 @@ function optionsPanel() {
class: 'mono-input',
autocomplete: 'off',
value: ui.confirmPass,
- onInput: (e) => (ui.confirmPass = e.target.value),
+ onInput: (e) => { ui.confirmPass = e.target.value; if (ui.unlockError && ui.confirmPass === ui.passphrase) { ui.unlockError = ''; render(); } },
})
)
: null;
@@ -1929,8 +2047,9 @@ async function activateAccount(acc, opts = {}) {
function signInAnother() {
clearSeedDrafts();
ui.fromWallet = true; ui.unlockError = ''; ui.screen = 'unlock';
- // Starts on Create new (its Generate button), not the seed-import box.
- ui.unlockTab = 'create'; ui.createStep = 'gen'; ui.draftMnemonic = ''; ui.confirm = [];
+ ui.addFlow = true; ui.addStep = 'welcome'; ui.nostrLoginOpen = false; ui.nostrLoginError = '';
+ // Opens on the front door's two choices (addAccountScreen).
+ ui.unlockTab = 'create'; ui.createStep = 'intro'; ui.draftMnemonic = ''; ui.confirm = [];
ui.profilePk = null; ui.profEdit = null; ui.profEditFilled = false; ui.chatOpen = false;
render();
}
@@ -2803,7 +2922,7 @@ function lock({ offerPassword = false } = {}) {
ui.confirmRemove = null;
ui.editId = null;
ui.editLabel = '';
- ui.createStep = 'gen';
+ ui.createStep = 'intro';
ui.draftMnemonic = '';
ui.importText = '';
ui.entropyPage = null;
@@ -3385,7 +3504,7 @@ function goHome() {
ui.sendError = '';
ui.fromWallet = false;
} else if ((ui.onb && ui.onb.step !== 'welcome')
- || ui.draftMnemonic || ui.createStep !== 'gen' || ui.importText) {
+ || ui.draftMnemonic || !['intro', 'gen'].includes(ui.createStep) || ui.importText) {
// Mid-create with no wallet minted yet (wizard step, a generated seed on
// screen, the confirm quiz, a pasted import): the logo backs out to the
// start page this began from — not the vault prompt, which reads as a
@@ -3395,7 +3514,7 @@ function goHome() {
ui.nostrLoginOpen = false;
ui.screen = 'unlock';
ui.unlockTab = 'create';
- ui.createStep = 'gen';
+ ui.createStep = 'intro';
ui.draftMnemonic = '';
clearSeedDrafts();
ui.entropyPage = null;
@@ -3414,7 +3533,7 @@ function goHome() {
} else {
ui.screen = 'unlock';
ui.unlockTab = 'create';
- ui.createStep = 'gen';
+ ui.createStep = 'intro';
ui.draftMnemonic = '';
ui.confirm = [];
ui.unlockError = '';
@@ -3494,8 +3613,8 @@ function accountsScreen() {
if (aw.from === 'new') {
ui.newWalletKind = kind;
ui.addWallet = null;
- ui.draftMnemonic = ''; ui.createStep = 'gen'; ui.confirm = []; ui.passphrase = ''; ui.confirmPass = ''; ui.passOpen = false;
- ui.showPass = false; ui.screen = 'unlock'; ui.unlockTab = 'create'; ui.fromWallet = true; ui.unlockError = '';
+ ui.draftMnemonic = ''; ui.createStep = 'intro'; ui.confirm = []; ui.passphrase = ''; ui.confirmPass = ''; ui.passOpen = false;
+ ui.showPass = false; ui.screen = 'unlock'; ui.unlockTab = 'create'; ui.fromWallet = true; ui.unlockError = ''; ui.addFlow = false;
render();
} else {
createDerivedWallet(aw.from, kind);
@@ -4190,7 +4309,7 @@ function onboardScreen() {
const startCreate = () => {
ui.unlockTab = 'create';
ui.unlockError = '';
- ui.createStep = 'gen';
+ ui.createStep = 'intro';
if (!ui.draftMnemonic) ui.draftRandom = ui.draftMnemonic = newMnemonic();
o.step = 'seed';
render();
@@ -4292,26 +4411,36 @@ function onboardScreen() {
ui.tab = 'receive';
render();
};
+ // Later means later, not never: seedSeen stays false, so the wallet
+ // keeps a quiet reminder until the phrase has actually been seen.
+ // Except on the way to a coinos.io move: the balance about to arrive
+ // must have its twelve words written down first, so Later isn't offered.
+ const later = migrateLinkPending() ? null : h('button', { class: 'btn-ghost btn-block', onClick: () => {
+ if (canOfferSpending()) { o.step = 'spend'; o.enterAddr = undefined; render(); return; }
+ ui.onb = null;
+ try { localStorage.removeItem(ONB_STEP_KEY); } catch {}
+ ui.account = 'savings';
+ try { localStorage.setItem(ACCOUNT_KEY, 'savings'); } catch {}
+ ui.tab = 'receive';
+ render();
+ } }, t('onbBackupLater'));
+ // a heads-up first: the next screen shows the secret (Adam, 2026-11-07)
+ if (!o.backupShown) {
+ return page([
+ title(t('onbBackupTitle')),
+ h('p', { class: 'muted', style: 'margin:0' }, t('onbBackupBody')),
+ h('p', { class: 'muted', style: 'margin:0' }, t('seedIntroPrivate')),
+ h('button', { id: 'onb-backup-show', class: 'btn-primary btn-block', style: 'padding:14px', onClick: () => { o.backupShown = true; render(); } }, t('onbBackupShow')),
+ later,
+ ]);
+ }
return page([
title(t('onbBackupTitle')),
- h('p', { class: 'muted', style: 'margin:0' }, t('onbBackupBody')),
h('div', { class: 'words', style: '--rows-3:' + Math.ceil(words.length / 3) + ';--rows-2:' + Math.ceil(words.length / 2) }, words.map((w, i) =>
h('div', { class: 'w' }, h('span', { class: 'n' }, i + 1), h('span', { class: 't' }, w)))),
h('div', { class: 'row gap6' }, copyBtn(acc.mnemonic, t('copyPhrase'))),
h('button', { class: 'btn-primary btn-block', style: 'padding:14px', onClick: done }, t('onbBackupDone')),
- // Later means later, not never: seedSeen stays false, so the wallet
- // keeps a quiet reminder until the phrase has actually been seen.
- // Except on the way to a coinos.io move: the balance about to arrive
- // must have its twelve words written down first, so Later isn't offered.
- migrateLinkPending() ? null : h('button', { class: 'btn-ghost btn-block', onClick: () => {
- if (canOfferSpending()) { o.step = 'spend'; o.enterAddr = undefined; render(); return; }
- ui.onb = null;
- try { localStorage.removeItem(ONB_STEP_KEY); } catch {}
- ui.account = 'savings';
- try { localStorage.setItem(ACCOUNT_KEY, 'savings'); } catch {}
- ui.tab = 'receive';
- render();
- } }, t('onbBackupLater')),
+ later,
]);
}
if (o.step === 'seed') {
@@ -4322,7 +4451,7 @@ function onboardScreen() {
title(t(ui.unlockTab === 'import' ? 'onbRestoreTitle' : 'onbSeedTitle')),
unlockCard(),
// the entropy page brings its own Back — one is enough
- ui.entropyPage ? null : h('button', { class: 'linklike small', onClick: () => { ui.unlockError = ''; o.step = ui.unlockTab === 'import' ? 'doors' : 'welcome'; render(); } }, t('back')),
+ ui.entropyPage ? null : h('button', { class: 'linklike small', onClick: () => { if (createStepBack()) return; ui.unlockError = ''; o.step = ui.unlockTab === 'import' ? 'doors' : 'welcome'; render(); } }, t('back')),
]);
}
if (o.step === 'signin') {