diff options
| author | Adam Soltys <[email protected]> | 2026-09-13 09:48:54 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-13 09:48:54 -0700 |
| commit | 38b3d091e8e1bece958c0d7e80a5ee78434de933 (patch) | |
| tree | a6ef243527c8f8c9499d90ffb9e2c43d9d03a8b2 /src | |
| parent | 810d3d8bfd523a8a2a4a51cd2e426d72af049d13 (diff) | |
| download | coinosv3-38b3d091e8e1bece958c0d7e80a5ee78434de933.tar.gz coinosv3-38b3d091e8e1bece958c0d7e80a5ee78434de933.zip | |
The front door points at your own keys.
Four buttons of equal weight, and only the sign-in ones said what they
did. "Get started" said nothing, so the concrete offer on that screen was
Google — which is a convenience wrapped around a seed phrase, not an
alternative to one.
Now the page says what the wallet is before it offers a button, the way
in reads "Create a new wallet" with the cost under it (a minute, twelve
words), and the three sign-ins sit below a line labelled "or, if you
already have an account".
And the second half of the same problem: a wallet that arrives by sign-in
has a recovery phrase its owner has never seen and nobody can reissue.
So the wizard shows it once, right after the first login, while it still
reads as setting up rather than as a warning out of nowhere. "I've
written it down" is the way on; "Later" is a real answer, and the wallet
then keeps one quiet line — not a modal, not dismissible, gone the moment
the phrase is actually seen, here or in settings.
seedSeen records that per account: creating shows the words, importing
means they typed them, revealing in settings counts. Only a derived seed
starts false, which is exactly the case worth asking about.
tools/onboard-backup-test.js covers both halves, including that "Later"
leaves a reminder that survives a reload and leads somewhere.
The sixteen tests that clicked the old button by its text are updated,
and it has an id now so its copy can change without breaking them again.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 119 | ||||
| -rw-r--r-- | src/i18n.js | 11 | ||||
| -rw-r--r-- | src/style.css | 9 |
3 files changed, 121 insertions, 18 deletions
@@ -1280,6 +1280,12 @@ async function enterWallet(mnemonic, passphrase, opts = {}) { mnemonic: (mnemonic || '').trim().replace(/\s+/g, ' '), passphrase: passphrase || '', }); + // Has this person ever had the recovery phrase in front of them? Creating + // a wallet shows it; importing one means they typed it. A sign-in DERIVES + // it — Google, a passkey and a nostr key all end up at a seed the user has + // never seen, and which nobody can reissue for them. That is the one case + // worth asking about, so it's recorded once and never guessed at again. + if (acc.seedSeen === undefined) acc.seedSeen = !opts.nostrPubkey; await activateAccount(acc, { ...opts, fresh: true }); } @@ -2888,7 +2894,12 @@ function recoveryCard(a) { a.passphrase ? copyBtn(a.passphrase, t('copyPassphrase')) : null, h('button', { class: 'btn-sm grow', onClick: () => { ui.revealShown = false; render(); } }, t('hide'))) : h('div', { class: 'row gap6' }, - h('button', { class: 'btn-primary grow', onClick: () => { ui.revealShown = 'words'; render(); } }, t('revealWords')), + h('button', { class: 'btn-primary grow', onClick: () => { + ui.revealShown = 'words'; + // seeing it here counts: no need to be asked again elsewhere + if (!a.seedSeen) { a.seedSeen = true; persistAccounts(); if (a.persisted) writeVault(); } + render(); + } }, t('revealWords')), h('button', { class: 'btn-sm', onClick: () => { ui.revealShown = false; render(); } }, t('hide'))) ); } @@ -3080,14 +3091,23 @@ function onboardScreen() { // the login finishes. if ((o.step === 'welcome' || o.step === 'seed' || o.step === 'signin') && ui.screen === 'wallet' && activeAccount() && !ui.nostrLoginBusy) { - ui.onb = null; - try { localStorage.removeItem(ONB_STEP_KEY); } catch {} - // On-chain first: the wizard's promise is "your keys, your coins", and - // Spending stays out of sight until the user sets it up. - ui.account = 'savings'; - try { localStorage.setItem(ACCOUNT_KEY, 'savings'); } catch {} - ui.tab = 'history'; - return null; // the caller falls through to the wallet itself + // A wallet that arrived by SIGN-IN has a recovery phrase its owner has + // never seen. Show it once, here, while it still reads as part of + // setting up — rather than leaving it buried in settings until the day + // they lose the Google account and find out it was never a backup. + // Handled further down, where page()/title() exist. + const arrived = activeAccount(); + if (arrived && arrived.mnemonic && !arrived.seedSeen) o.step = 'backup'; + else { + ui.onb = null; + try { localStorage.removeItem(ONB_STEP_KEY); } catch {} + // On-chain first: the wizard's promise is "your keys, your coins", and + // Spending stays out of sight until the user sets it up. + ui.account = 'savings'; + try { localStorage.setItem(ACCOUNT_KEY, 'savings'); } catch {} + ui.tab = 'history'; + return null; // the caller falls through to the wallet itself + } } if (o.step === 'spend') { const addr = featureHook('namesAddress'); @@ -3164,14 +3184,24 @@ function onboardScreen() { return page([ h('div', { class: 'onb-hero' }, brandHeader(false)), title(t('onbWelcomeTitle')), - // Starting is the primary path; bringing a nostr account is the - // alternative, offered here rather than as a question of its own. - h('button', { class: 'btn-primary btn-block', style: 'font-size:17px;padding:14px', onClick: () => { - ui.unlockTab = 'create'; - ui.unlockError = ''; - o.step = 'seed'; - render(); - } }, t('onbStart')), + // What this wallet IS, before the buttons. Four equally weighted + // buttons with only the sign-in ones saying what they do sent people + // straight past the seed phrase — which is the wallet — into a login + // that is only a convenience wrapped around it. + h('p', { class: 'muted', style: 'margin:0' }, t('onbWelcomeBody')), + // Making your own keys is the path; a sign-in is the alternative, + // below the line. + h('button', { + // a stable hook for tests, so this button survives its own copy + id: 'onb-create', + class: 'btn-primary btn-block', style: 'font-size:17px;padding:14px', onClick: () => { + ui.unlockTab = 'create'; + ui.unlockError = ''; + o.step = 'seed'; + render(); + } }, t('onbStart')), + h('p', { class: 'small muted', style: 'margin:-6px 0 0;text-align:center' }, t('onbStartHint')), + h('div', { class: 'onb-or' }, h('span', {}, t('onbOrSignIn'))), // All the sign-in doors right on the welcome screen (Google, passkey, // Nostr) — the feature supplies them; the single-button fallback keeps // builds without it working. @@ -3186,6 +3216,42 @@ function onboardScreen() { ui.onbError ? h('div', { class: 'notice err' }, ui.onbError) : null, ]); } + if (o.step === 'backup') { + const acc = activeAccount(); + // the wallet went away (switched, logged out) — nothing to back up + if (!acc || !acc.mnemonic) { ui.onb = null; try { localStorage.removeItem(ONB_STEP_KEY); } catch {} return null; } + const words = acc.mnemonic.split(' '); + const done = () => { + acc.seedSeen = true; + persistAccounts(); + if (acc.persisted) writeVault(); + ui.onb = null; + try { localStorage.removeItem(ONB_STEP_KEY); } catch {} + ui.account = 'savings'; + try { localStorage.setItem(ACCOUNT_KEY, 'savings'); } catch {} + ui.tab = 'history'; + render(); + }; + return page([ + title(t('onbBackupTitle')), + h('p', { class: 'muted', style: 'margin:0' }, t('onbBackupBody')), + h('div', { class: 'warn-box' }, t('writeDownWarn')), + h('div', { class: 'words' }, 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. + h('button', { class: 'btn-ghost btn-block', onClick: () => { + ui.onb = null; + try { localStorage.removeItem(ONB_STEP_KEY); } catch {} + ui.account = 'savings'; + try { localStorage.setItem(ACCOUNT_KEY, 'savings'); } catch {} + ui.tab = 'history'; + render(); + } }, t('onbBackupLater')), + ]); + } if (o.step === 'seed') { // The classic create/import card, inside the wizard: getting started // means seeing (or bringing) your seed phrase, not a wallet minted @@ -3439,12 +3505,31 @@ function walletScreen() { brandHeader(true), h('div', { class: 'mt16' }, balanceCard()), ui.offlineFallback && wallet.offline ? offlineBanner() : null, + backupNotice(), ...featureAll('walletNotices'), tabsBar(), pane ); } +// A wallet whose recovery phrase has never been on screen keeps one quiet +// line about it. "Later" is a real answer, so this is a row and not a modal — +// but it doesn't dismiss, because the risk doesn't go away on its own. It +// disappears the moment the phrase is actually seen (here or in settings). +function backupNotice() { + const a = activeAccount(); + if (!a || !a.mnemonic || a.seedSeen || wallet.watchOnly) return null; + return h('div', { class: 'small muted', style: 'margin:10px 0 0;text-align:center' }, + t('backupReminder'), + ' ', + h('button', { class: 'linklike small', onClick: () => { + ui.tab = 'settings'; + ui.settingsPage = 'wallet'; + ui.revealShown = 'masked'; + render(); + } }, t('backupReminderGo'))); +} + function offlineBanner() { return h( 'div', diff --git a/src/i18n.js b/src/i18n.js index 3faa843..6a46823 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -716,7 +716,16 @@ const STR = { or: 'or', nlSignIn: 'Sign in', onbWelcomeTitle: 'Welcome to coinos', - onbStart: 'Get started', + onbWelcomeBody: 'Bitcoin you actually hold. Your wallet is twelve words — nobody else ever has them, and they work in any wallet, anywhere.', + onbStart: 'Create a new wallet', + onbStartHint: "Takes a minute: write down twelve words and it's yours", + onbOrSignIn: 'or, if you already have an account', + onbBackupTitle: 'Write down your recovery phrase', + onbBackupBody: "Signing in was the easy part — but the sign-in isn't your wallet. These twelve words are. They're the only way back in if you lose access to that account, and nobody can reissue them for you.", + onbBackupDone: "I've written it down", + onbBackupLater: 'Later', + backupReminder: 'Your recovery phrase is still unwritten.', + backupReminderGo: 'Write it down', onbSeedTitle: 'Set up your wallet', onbSigninTitle: 'Sign in', onbLegacyTitle: 'Move your coinos.io account', diff --git a/src/style.css b/src/style.css index a16101c..c9b4368 100644 --- a/src/style.css +++ b/src/style.css @@ -865,6 +865,15 @@ body::after { column to the 420px cap even on narrower phones, overflowing the page. */ .onb { width: 100%; max-width: 420px; margin: 8vh auto 0; padding: 0 8px; } .onb-title { margin: 0; font-size: 26px; letter-spacing: -0.02em; } +/* The line between making your own keys and signing in with someone else's + account. Without it the four buttons read as four equal choices. */ +.onb-or { + display: flex; align-items: center; gap: 12px; margin: 4px 0 -4px; + color: var(--faint); font-size: 13px; +} +.onb-or::before, .onb-or::after { + content: ''; flex: 1; height: 1px; background: var(--line); +} .onb-hero { margin-bottom: 8px; } .onb-punks { display: flex; gap: 10px; overflow-x: auto; padding: 6px 2px; } .onb-punk { width: 56px; height: 56px; border-radius: 50%; flex-shrink: 0; cursor: pointer; border: 3px solid transparent; transition: transform .15s, border-color .15s; } |
