aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-11-07 08:08:25 -0700
committerAdam Soltys <[email protected]>2026-11-07 08:08:25 -0700
commit1d689216a87311298f362bb6b3d29c0ce5246821 (patch)
tree07d876a00145979872988b7a73101b521fce31a5 /src
parent88585916cec0343b4c4fc3b75d931ad460ad00cc (diff)
downloadcoinosv3-1d689216a87311298f362bb6b3d29c0ce5246821.tar.gz
coinosv3-1d689216a87311298f362bb6b3d29c0ce5246821.zip
A new seed phrase comes in three steps: a heads-up, the words, then one word to pick. "Reintroduce the seed phrase preamble telling users to write it down but don't give it a warning icon … an intermediary step before we display the seed phrase … instead of verify and skip buttons just have a next and another intermediate step that asks for just the 5th seed word and gives a few options to click and also a skip button" (Adam, 2026-11-07). Creating a wallet (front door, Accounts, signInAnother) now opens on a plain note, "Next you'll see your seed phrase: 12 words … Write them down on paper, in order … make sure no one is looking", with Show seed phrase; the words page has one Next; the check asks "Which one is word #5?" with four of the phrase's own words as buttons (a wrong one turns red and says "Not that one"), Skip, and Show the words again. The gift claim's new-wallet backup gets the same three steps in place of its warning box and typed three-word check, and a sign-in's "Write down your recovery phrase" shows its explanation first and the words behind Show seed phrase (Later still on both). createStep starts at 'intro'; pickConfirm returns { index, options, wrong }
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VMan6Ts5Tktega8VbLg31q
Diffstat (limited to 'src')
-rw-r--r--src/app.js144
-rw-r--r--src/features/gifts.js64
-rw-r--r--src/i18n.js9
-rw-r--r--src/style.css4
4 files changed, 125 insertions, 96 deletions
diff --git a/src/app.js b/src/app.js
index 9ce7190..5a1f3ed 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)
@@ -1345,12 +1345,12 @@ function unlockCard() {
// 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 swap = wizard && ui.createStep !== 'confirm' ? 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;
@@ -1476,6 +1476,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
@@ -1517,10 +1538,11 @@ function createPane() {
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
+ // still on screen — the check only asks for one word
if (ui.passphrase && ui.confirmPass !== ui.passphrase) {
ui.unlockError = t('passphraseMismatch'); render(); return;
}
@@ -1530,57 +1552,45 @@ function createPane() {
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 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 = 'gen'; 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')),
+ h('button', { class: 'linklike small', style: 'align-self:center', onClick: () => { ui.createStep = 'gen'; ui.unlockError = ''; render(); } }, t('quizBack')),
);
}
-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
@@ -1930,7 +1940,7 @@ 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.unlockTab = 'create'; ui.createStep = 'intro'; ui.draftMnemonic = ''; ui.confirm = [];
ui.profilePk = null; ui.profEdit = null; ui.profEditFilled = false; ui.chatOpen = false;
render();
}
@@ -2803,7 +2813,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 +3395,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 +3405,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 +3424,7 @@ function goHome() {
} else {
ui.screen = 'unlock';
ui.unlockTab = 'create';
- ui.createStep = 'gen';
+ ui.createStep = 'intro';
ui.draftMnemonic = '';
ui.confirm = [];
ui.unlockError = '';
@@ -3494,7 +3504,7 @@ 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.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 = '';
render();
} else {
@@ -4190,7 +4200,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 +4302,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') {
diff --git a/src/features/gifts.js b/src/features/gifts.js
index 42e794d..f23ecc8 100644
--- a/src/features/gifts.js
+++ b/src/features/gifts.js
@@ -288,51 +288,47 @@ export function giftsFeature(ctx) {
h('h2', { style: 'margin:0' }, claimed ? t('claimedTitle') : t('newWalletTitle')),
h('p', { class: 'muted', style: 'margin:0' }, claimed ? t('claimedBody') : t('newWalletBody'))
),
- h('div', { class: 'card col' },
- h('h3', {}, t('recoveryPhrase')),
- h('div', { class: 'warn-box' }, t('writeDownWarn')),
- 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(wallet.mnemonic, t('copyPhrase'))),
- h('button', { class: 'btn-primary btn-block', onClick: () => { ui.confirm = pickConfirm(words); ui.claimError = ''; ui.claimStep = 'verify'; render(); } }, t('verifyBackup')),
- h('button', { class: 'btn-block', onClick: finishClaim }, t('skipVerification'))
- )
+ // a heads-up before the words, then the words, then one word to
+ // check (Adam, 2026-11-07) — the same three steps as a new wallet
+ !ui.claimShown
+ ? h('div', { class: 'card col' },
+ h('h3', { style: 'margin-top:0' }, t('recoveryPhrase')),
+ h('p', { style: 'margin:0' }, t('seedIntroBody', { n: words.length })),
+ h('p', { class: 'muted', style: 'margin:0' }, t('seedIntroPrivate')),
+ h('button', { id: 'claim-seed-show', class: 'btn-primary btn-block', onClick: () => { ui.claimShown = true; render(); } }, t('seedIntroShow')))
+ : h('div', { class: 'card col' },
+ h('h3', { style: 'margin-top:0' }, t('recoveryPhrase')),
+ 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(wallet.mnemonic, t('copyPhrase'))),
+ h('button', { id: 'claim-seed-next', class: 'btn-primary btn-block', onClick: () => { ui.confirm = pickConfirm(words); ui.claimError = ''; ui.claimStep = 'verify'; render(); } }, t('next')))
);
}
if (ui.claimStep === 'verify') {
- // Same word-confirmation as new-wallet creation: prove the recipient wrote
- // the phrase down before sending them off into their freshly funded wallet.
const words = wallet.mnemonic.split(' ');
+ const q = ui.confirm;
+ if (!q || !q.options) { ui.claimStep = 'backup'; ui.claimShown = true; return claimScreen(); }
return h(
'div',
{ class: 'col', style: 'gap:16px' },
brandHeader(false),
h('div', { class: 'card col' },
h('h3', { style: 'margin-top:0' }, t('recoveryPhrase')),
- h('p', { class: 'muted', style: 'margin:0' }, 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()),
- })
- )
- ),
- ui.claimError && h('div', { class: 'notice err' }, ui.claimError),
- h('div', { class: 'row gap6' },
- h('button', { class: 'btn-ghost', onClick: () => { ui.claimStep = 'backup'; ui.claimError = ''; 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.includes(w) ? ' wrong' : ''),
onClick: () => {
- const ok = ui.confirm.every((c) => c.value.toLowerCase() === words[c.index]);
- if (!ok) { ui.claimError = t('wordsMismatch'); render(); return; }
- finishClaim();
+ if (w === words[q.index]) { ui.claimError = ''; finishClaim(); return; }
+ if (!q.wrong.includes(w)) q.wrong.push(w);
+ ui.claimError = t('quizWrong'); render();
},
- }, t('openWallet'))
- ),
- h('button', { class: 'btn-block', onClick: finishClaim }, t('skipVerification'))
+ }, w))),
+ ui.claimError && h('div', { class: 'notice err' }, ui.claimError),
+ h('button', { id: 'claim-seed-skip', class: 'btn-ghost btn-block', onClick: () => { ui.claimError = ''; finishClaim(); } }, t('skip')),
+ h('button', { class: 'linklike small', style: 'align-self:center', onClick: () => { ui.claimStep = 'backup'; ui.claimShown = true; ui.claimError = ''; render(); } }, t('quizBack')),
)
);
}
@@ -366,7 +362,7 @@ export function giftsFeature(ctx) {
// view the claim on a block explorer, or head back to the home screen.
h('button', { class: 'btn-primary btn-block', onClick: () => {
ui.claimTaken = null; ui.claimedAmount = 0;
- if (activeAccount() && activeAccount().provisional) { ui.claimStep = 'backup'; commitAccount(); }
+ if (activeAccount() && activeAccount().provisional) { ui.claimStep = 'backup'; ui.claimShown = false; commitAccount(); }
else { ui.screen = 'wallet'; ui.claimStep = null; landAccount(); }
render();
} }, activeAccount() && activeAccount().provisional ? t('createWalletAnyway') : t('goToWallet')),
@@ -488,7 +484,7 @@ export function giftsFeature(ctx) {
commitAccount(); // claimed — keep a fresh gift wallet (no-op for an existing one)
if (ui.claimFrom) hook('followGiftSender', ui.claimFrom);
if (wasProvisional) {
- ui.claimStep = 'backup'; // fresh wallet — show the new seed to back up
+ ui.claimStep = 'backup'; ui.claimShown = false; // fresh wallet — show the new seed to back up
} else {
ui.screen = 'wallet'; // existing wallet — straight into it, no new seed to back up
ui.claimStep = null;
diff --git a/src/i18n.js b/src/i18n.js
index e45fa49..369deb2 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -197,6 +197,15 @@ const STR = {
skipVerification: 'Skip verification',
confirmBackupIntro: 'Confirm your backup by entering the requested words.',
wordN: 'Word #{n}',
+ seedIntroBody: 'Next you’ll see your seed phrase: {n} words that are your wallet. Write them down on paper, in order, and keep them somewhere safe. They’re the only way to get your coins back if you lose this device.',
+ seedIntroPrivate: 'Anyone who sees them can take your coins, so make sure no one is looking at your screen.',
+ seedIntroShow: 'Show seed phrase',
+ next: 'Next',
+ skip: 'Skip',
+ quizPrompt: 'Which one is word #{n}?',
+ quizWrong: 'Not that one. Check what you wrote down.',
+ quizBack: 'Show the words again',
+ onbBackupShow: 'Show seed phrase',
reenterPassphrase: 'Re-enter passphrase',
back: '← Back',
cancel: 'Cancel',
diff --git a/src/style.css b/src/style.css
index dfe9b9d..d753604 100644
--- a/src/style.css
+++ b/src/style.css
@@ -344,6 +344,10 @@ textarea.send-dest::placeholder { font-family: var(--sans); }
eight-letter word ("resemble") pushed the third column out past the
card's padding. The cells are a touch tighter so every BIP-39 word (eight
letters at most) still fits whole in a third of a narrow card. */
+/* The one-word check after the phrase: four of its words, tap the right one. */
+.quiz-opts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
+.quiz-opt { font-family: var(--mono); font-size: 16px; padding: 14px 8px; }
+.quiz-opt.wrong { border-color: var(--red); color: var(--red); background: var(--red-soft); }
.words { display: grid; grid-auto-flow: row; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
.words .w {
display: flex; align-items: baseline; gap: 5px; background: var(--surface2); min-width: 0;