aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/app.js363
-rw-r--r--src/features/messages.js8
-rw-r--r--src/features/names.js2
-rw-r--r--src/features/nostrlogin.js130
-rw-r--r--src/features/pos.js30
-rw-r--r--src/i18n.js32
-rw-r--r--src/nostr-login.js12
-rw-r--r--src/passkey.js6
-rw-r--r--src/style.css27
-rw-r--r--tools/balance-settings-row-test.js13
-rw-r--r--tools/chat-layout-test.js2
-rw-r--r--tools/footer-support-test.js41
-rw-r--r--tools/onboard-backup-test.js4
-rw-r--r--tools/pos-test.js20
-rw-r--r--tools/send-lnaddr-advance-test.js3
-rw-r--r--tools/signup-quick-test.js203
16 files changed, 772 insertions, 124 deletions
diff --git a/src/app.js b/src/app.js
index 3f1daf3..99cf565 100644
--- a/src/app.js
+++ b/src/app.js
@@ -16,7 +16,7 @@ import { nip98Header } from './nip98.js';
import { uploadPublicMedia, uploadStream, mediaUploadSigner, MediaUploadError } from './media-upload.js';
import { NOSTR_MARK } from './features/nostrlogin.js';
import { scanQr } from './scan.js';
-import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS } from './api.js';
+import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS, getArkConfig } from './api.js';
import { STAGING } from './build-flags.js';
import { buildFeatures, loadDeferredFeatures } from './features/index.js';
import { cacheReady, cacheStore } from './cache-store.js';
@@ -375,49 +375,42 @@ function setupFooter() {
return h('div', { class: 'footer small muted center' },
h('div', { class: 'row gap6', style: 'justify-content:center' }, lang));
}
+// The footer is the display preferences alone: the language this reads in,
+// and the currency amounts are shown in. Everything else that used to sit
+// here (credits, How it works, support, install, the theme, the commit) is
+// on Settings → About (Adam, 2026-11-15).
function footer() {
- return h(
- 'div',
- { class: 'footer small muted center' },
- h(
- 'div',
- {},
- t('footerMadeBy') + ' ',
- h('a', { href: 'https://adamsoltys.com', target: '_blank', rel: 'noopener' }, 'Adam Soltys'),
- h('span', { class: 'faint' }, ' · '),
- t('footerSourceOn') + ' ',
- h('a', { href: 'https://git.coinos.io/coinosv3.git/', target: '_blank', rel: 'noopener' }, 'git.coinos.io')
- ),
- h(
- 'div',
- { style: 'margin-top:4px' },
- h('button', { class: 'linklike', style: 'font-weight:400', onClick: openHowItWorks }, t('howItWorks')),
- // the hat shop: needs a wallet to pay from, so only once one is open
+ // with a wallet open they're in Settings (Adam, 2026-11-15); a visitor
+ // reading the public feed still gets them here
+ if (activeAccount()) return h('div', { class: 'footer', style: 'display:none' });
+ return h('div', { class: 'footer small muted center' },
+ h('div', { class: 'row gap6', style: 'justify-content:center' }, languagePicker(), currencyPicker()));
+}
+
+function aboutSettingsView() {
+ const row = (label, onClick, attrs = {}) => h('button', { class: 'btn-block', onClick, ...attrs }, label);
+ return h('div', { class: 'col', style: 'gap:16px' },
+ h('div', { class: 'card col about-card', style: 'gap:10px' },
+ h('h3', {}, t('aboutTitle')),
+ h('p', { class: 'small muted', style: 'margin:0' },
+ t('footerMadeBy').replace(/^./, (c) => c.toUpperCase()) + ' ',
+ h('a', { href: 'https://adamsoltys.com', target: '_blank', rel: 'noopener' }, 'Adam Soltys'),
+ '. ' + t('footerSourceOn').replace(/^./, (c) => c.toUpperCase()) + ' ',
+ h('a', { href: 'https://git.coinos.io/coinosv3.git/', target: '_blank', rel: 'noopener' }, 'git.coinos.io'), '.'),
+ row(t('howItWorks'), openHowItWorks),
+ // the hat shop pays from the wallet, so only once one is open
activeAccount() && FEATURES.some((f) => f.openHatShop)
- ? h('span', {}, h('span', { class: 'faint' }, ' · '),
- h('button', { class: 'linklike', style: 'font-weight:400', onClick: () => { clearFeatureNav(); ui.screen = 'wallet'; featureHook('openHatShop'); } }, t('footerSupport')))
+ ? row(t('footerSupport'), () => { clearFeatureNav(); ui.screen = 'wallet'; featureHook('openHatShop'); }, { id: 'about-support' })
: null,
- // Chrome no longer prompts to install on its own — surface our own link
- // once it reports the app is installable. We render from a persisted flag
- // (not the live event) so the link is present on the first paint after a
- // refresh, avoiding a layout shift when beforeinstallprompt fires late.
- installable()
- ? h('span', {}, h('span', { class: 'faint' }, ' · '),
- h('button', { class: 'linklike', style: 'font-weight:400', onClick: triggerInstall }, t('installApp')))
- : null,
- h('span', { class: 'faint' }, ' · '),
- h('button', { class: 'linklike', style: 'font-weight:400', onClick: toggleTheme }, resolvedTheme() === 'dark' ? t('lightMode') : t('darkMode'))
- ),
- // Display preferences live together: the language this reads in, and the
- // money amounts are shown in. (The currency sat under Settings → Network
- // for exactly one day, where nobody found it.)
- h('div', { class: 'row gap6', style: 'margin-top:8px;justify-content:center' }, languagePicker(), currencyPicker()),
- // The commit stamp settles "is my PWA fresh?" AND "what code is this?" in
- // one glance — its own last line, so it never wraps the credits on mobile.
- h('div', { class: 'faint', style: 'margin-top:6px' },
- 'Running git commit ',
- h('a', { class: 'faint', href: 'https://git.coinos.io/coinosv3.git/commit/?id={{COMMIT}}', target: '_blank', rel: 'noopener' }, '{{COMMIT}}'))
- );
+ // Chrome no longer prompts to install by itself: our own button, once
+ // it has said the app is installable (a persisted flag, see installable)
+ installable() ? row(t('installApp'), triggerInstall) : null,
+ row(resolvedTheme() === 'dark' ? t('lightMode') : t('darkMode'), toggleTheme, { id: 'about-theme' }),
+ // "is my PWA fresh?" and "what code is this?" in one line
+ h('div', { class: 'small faint', style: 'margin-top:2px' },
+ 'Running git commit ',
+ h('a', { class: 'faint', href: 'https://git.coinos.io/coinosv3.git/commit/?id={{COMMIT}}', target: '_blank', rel: 'noopener' }, '{{COMMIT}}'))),
+ h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.settingsPage = null; render(); } }, t('back')));
}
const THEME_KEY = 'btc-wallet-theme';
@@ -1883,7 +1876,16 @@ async function enterWallet(mnemonic, passphrase, opts = {}) {
// 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;
+ // A sign-up (Create an account) shows no phrase either: it stays one tap
+ // away in Settings, next to the other ways back in (passkey, Google, nostr).
+ if (acc.seedSeen === undefined) acc.seedSeen = !opts.nostrPubkey && !opts.unseen;
+ if (opts.signup && !acc.signupAt) acc.signupAt = Date.now();
+ // Spending first: an account made by signing up has no Savings side until
+ // its owner adds one (or on-chain money turns up — see savingsEvident). A
+ // recovery sign-in carries the same shape over from the account's backup.
+ if (opts.spendingOnly && !acc.kind && !acc.deriveIndex && !acc.savingsAdded && acc.type === 'full') {
+ acc.kind = 'spending'; acc.savingsLater = true;
+ } else if (opts.signup && acc.kind === 'spending') acc.savingsLater = true;
await activateAccount(acc, { ...opts, fresh: true });
}
@@ -2193,7 +2195,12 @@ function defaultLabel(type) {
function loadWatchAccounts() {
try {
const dir = JSON.parse(localStorage.getItem(WATCH_KEY) || '[]');
- return dir.filter((d) => d.xpub).map((d) => ({ id: d.id, label: d.label, type: 'watch', xpub: d.xpub, autoLock: d.autoLock || 0, network: d.network, nostrPk: d.nostrPk }));
+ return dir.filter((d) => d.xpub).map((d) => ({ id: d.id, label: d.label, type: 'watch', xpub: d.xpub, autoLock: d.autoLock || 0, network: d.network, nostrPk: d.nostrPk,
+ // a sign-up's Spending-only shape and its ways back in outlive the session
+ ...(d.spendingOnly ? { kind: 'spending', savingsLater: true } : {}),
+ ...(d.signupAt ? { signupAt: d.signupAt } : {}),
+ ...(d.waysBackPrompted ? { waysBackPrompted: d.waysBackPrompted } : {}),
+ ...(Array.isArray(d.recovery) && d.recovery.length ? { recovery: d.recovery } : {}) }));
} catch { return []; }
}
function saveDirectory() {
@@ -2210,7 +2217,11 @@ function saveDirectory() {
|| (wallet.nostrPubkey && wallet.nostrPubkey()) || a.nostrPk;
} catch {}
}
- return { id: a.id, label: a.label, xpub: a.xpub, autoLock: a.autoLock || 0, network: a.network, nostrPk: a.nostrPk, seedSeen: !!a.seedSeen };
+ return { id: a.id, label: a.label, xpub: a.xpub, autoLock: a.autoLock || 0, network: a.network, nostrPk: a.nostrPk, seedSeen: !!a.seedSeen,
+ ...(a.kind === 'spending' && a.savingsLater ? { spendingOnly: true } : {}),
+ ...(a.signupAt ? { signupAt: a.signupAt } : {}),
+ ...(a.waysBackPrompted ? { waysBackPrompted: a.waysBackPrompted } : {}),
+ ...(a.recovery && a.recovery.length ? { recovery: a.recovery } : {}) };
});
localStorage.setItem(WATCH_KEY, JSON.stringify(dir));
} catch {}
@@ -3231,13 +3242,17 @@ function settingsTab() {
// matters if the phone is lost tonight
case 'wallet':
if (ui.delAcct && a) return deleteAccountView(a);
- return page(a ? [recoveryCard(a), balanceSettingsCard(a), walletNameCard(a), pubkeyCard(a), historyExportCard(), deleteAccountCard(a)] : []);
+ // the phrase is one of the ways back in: its card shows only once
+ // Pen and paper asks for it (accounts without the card keep it always)
+ { const ways = a ? featureHook('waysBackCard') : null;
+ return page(a ? [ways, ways && !ui.revealShown ? null : recoveryCard(a), balanceSettingsCard(a), walletNameCard(a), pubkeyCard(a), historyExportCard(), deleteAccountCard(a)] : []); }
case 'payments': return page(featureAll('settingsCards').reverse());
case 'pos': return page(featureAll('posSettingsCards').reverse()); // the till's own card first, then the terminal's
case 'network': return page([networkCard(), explorerCard()]); // an old bookmark of the tile; the cards live under Advanced now
case 'nostr': return nostrSettingsView();
case 'notifications': return page(featureAll('notifySettingsCards').reverse());
case 'advanced': return advancedSettingsView();
+ case 'about': return aboutSettingsView();
}
const I = (d) => '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">' + d + '</svg>';
const TILE_ICONS = {
@@ -3248,6 +3263,7 @@ function settingsTab() {
// a till receipt: paper with a torn foot and two lines of figures
pos: I('<path d="M6 3h12v18l-2-1.5-2 1.5-2-1.5-2 1.5-2-1.5L6 21z"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="9" y1="12" x2="15" y2="12"/><line x1="12" y1="16" x2="15" y2="16"/>'),
notifications: I('<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/>'),
+ about: I('<circle cx="12" cy="12" r="9"/><line x1="12" y1="11" x2="12" y2="16.5"/><circle cx="12" cy="7.8" r=".6" fill="currentColor"/>'),
advanced: I('<line x1="4" y1="8" x2="20" y2="8"/><line x1="4" y1="16" x2="20" y2="16"/><circle cx="9" cy="8" r="2.2" fill="var(--surface,#fff)"/><circle cx="15" cy="16" r="2.2" fill="var(--surface,#fff)"/>'),
};
const tile = (id, label, desc) =>
@@ -3266,6 +3282,8 @@ function settingsTab() {
h('div', { class: 'row gap6', style: 'align-items:center;margin:2px 0 6px 4px' },
h('span', { style: 'display:flex;color:var(--muted)', html: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>' }),
h('h3', { style: 'margin:0' }, t('tabSettings'))),
+ // display preferences, right at the top: the language and the currency
+ h('div', { class: 'row gap6 settings-prefs', style: 'margin:0 0 4px' }, languagePicker(), currencyPicker()),
grid([
tile('wallet', t('settingsWallet'), t('settingsWalletDesc')),
tile('payments', t('settingsPayments'), t('settingsPaymentsDesc')),
@@ -3273,6 +3291,7 @@ function settingsTab() {
tile('nostr', t('nostrSettings'), t('settingsNostrDesc')),
tile('notifications', t('settingsNotifications'), t('settingsNotifDesc')),
tile('advanced', t('advancedSettings'), t('settingsAdvancedDesc')),
+ tile('about', t('aboutTitle'), t('settingsAboutDesc')),
]),
h('button', { class: 'btn-ghost btn-block', style: 'margin-top:6px', onClick: () => { ui.tab = wallet.offline ? 'settings' : 'history'; render(); } }, t('back'))
);
@@ -3891,12 +3910,12 @@ function recoveryCard(a) {
// a warning) before anything readable is over your shoulder. The second
// actually shows them.
if (!shown)
- return h('div', { class: 'card col' },
+ return h('div', { class: 'card col recovery-card' },
h('h3', {}, t('recoveryPhrase')),
h('p', { class: 'small muted', style: 'margin:0' }, t('recoveryDesc')),
h('button', { class: 'btn-block', onClick: () => { ui.revealShown = 'masked'; render(); } }, t('revealRecovery')));
const unmasked = shown === 'words';
- return h('div', { class: 'card col' },
+ return h('div', { class: 'card col recovery-card' },
h('h3', {}, t('recoveryPhrase')),
h('div', { class: 'warn-box' }, t('recoveryWarn')),
h('div', { class: 'words', style: '--rows-3:' + Math.ceil(words.length / 3) + ';--rows-2:' + Math.ceil(words.length / 2) }, words.map((w, i) =>
@@ -3929,17 +3948,25 @@ function recoveryCard(a) {
function balanceSettingsCard(a) {
if (a.id !== activeId) return null;
const views = a.kind ? [a.kind] : spendingActive() ? ['spending', 'savings'] : ['savings'];
- const row = (view) => h('button', {
- class: 'btn-ghost btn-block balance-settings-row', 'data-view': view,
- style: 'display:flex;align-items:center;gap:10px;text-align:left;padding:10px 4px',
- onClick: () => openWalletSettings(view),
- },
- h('span', { style: 'display:flex;color:var(--muted)', html: GEAR_ICON(18) }),
- h('span', { class: 'col', style: 'flex:1;gap:2px' },
- h('span', { style: 'font-weight:600' }, viewLabel(a, view)),
- h('span', { class: 'small muted' }, t(view === 'spending' ? 'spendingSettingsDesc' : 'savingsSettingsDesc'))),
- h('span', { class: 'muted' }, '›'));
- return h('div', { class: 'card col', style: 'gap:4px' }, h('h3', {}, t('balanceSettingsTitle')), ...views.map(row));
+ // Each balance wears its own mark in a tinted tile — the bolt and the
+ // bitcoin disc the balance card uses — rather than a gear beside one and a
+ // coin beside the other (Adam, 2026-11-15: "Icons and padding need work").
+ const BOLT = '<svg width="21" height="21" viewBox="0 0 24 24" fill="currentColor" style="display:block"><path d="M13.5 2 4 13.5h6.5L9.5 22 20 9.5h-6.6z"/></svg>';
+ const tile = (view) => h('span', { class: 'bs-tile ' + (view === 'spending' ? 'spend' : 'save'), html: view === 'spending' ? BOLT : BITCOIN_ICON(22) });
+ const rowOf = (attrs, view, title, desc, tail) => h('button', { class: 'balance-settings-row', ...attrs },
+ tile(view),
+ h('span', { class: 'col bs-text' },
+ h('span', { class: 'bs-title' }, title),
+ h('span', { class: 'small muted' }, desc)),
+ h('span', { class: 'bs-tail', 'aria-hidden': 'true' }, tail));
+ const row = (view) => rowOf({ 'data-view': view, onClick: () => openWalletSettings(view) }, view,
+ viewLabel(a, view), t(view === 'spending' ? 'spendingSettingsDesc' : 'savingsSettingsDesc'), '›');
+ // a sign-up's Spending-only account: Savings is one tap away
+ const addRow = a.kind === 'spending' && a.savingsLater && !a.deriveIndex
+ ? rowOf({ id: 'add-savings', onClick: () => { addSavings(a); toast(t('savingsAddedToast')); } }, 'savings',
+ t('addSavings'), t('addSavingsDesc'), '+')
+ : null;
+ return h('div', { class: 'card col', style: 'gap:8px' }, h('h3', {}, t('balanceSettingsTitle')), ...views.map(row), addRow);
}
function walletNameCard(a) {
const saveName = () => {
@@ -4274,6 +4301,21 @@ async function publicMediaUpload(file) {
// Whether the wizard can offer Spending at all: an Ark-capable build on a
// network with the names registrar, for a full (signing) account. Elsewhere
// the offer would be a spinner that never resolves.
+// Signing up (Adam, 2026-11-15): "we should just have them claim a name and
+// pick their avatar/cover and they should be in." Where a name can be
+// claimed — an Ark server and the names registrar on this network — Create
+// makes the account straight away: no twelve words to copy or check, no
+// Savings side. The phrase waits in Settings with the other ways back in
+// (passkey, Google, nostr), and a quiet line on the wallet asks for one.
+// Elsewhere (regtest, a build without Ark) there's no name to claim, the
+// wallet IS its seed, and Create keeps the classic phrase flow.
+function quickSignupOk() {
+ if (!['mainnet', 'mutinynet'].includes(getNetwork())) return false;
+ try {
+ if (!FEATURES.some((f) => f.id === 'ark') || !FEATURES.some((f) => f.id === 'names')) return false;
+ return !!getArkConfig();
+ } catch { return false; }
+}
function canOfferSpending() {
const acc = activeAccount();
if (!acc || acc.type !== 'full' || wallet.watchOnly) return false;
@@ -4310,7 +4352,8 @@ function onboardScreen() {
// 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';
+ // (a sign-up was never meant to see it: straight on to the name)
+ if (arrived && arrived.mnemonic && !arrived.seedSeen && !arrived.signupAt) o.step = 'backup';
else if (canOfferSpending()) {
// Before the home screen: one offer to set up Spending (a name and a
// payment address), with Not now a single tap away.
@@ -4318,10 +4361,11 @@ function onboardScreen() {
} 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 {}
+ // On-chain first for a seed wallet: the wizard's promise is "your
+ // keys, your coins". A sign-up is Spending alone.
+ const home = arrived && arrived.kind ? arrived.kind : 'savings';
+ ui.account = home;
+ try { localStorage.setItem(ACCOUNT_KEY, home); } catch {}
ui.tab = 'receive';
return null; // the caller falls through to the wallet itself
}
@@ -4415,6 +4459,24 @@ function onboardScreen() {
}
// Making your own keys: the words are the wallet, so show them at once,
// no Generate to tap.
+ // Create an account: a fresh seed opens at once as a Spending-only
+ // account; the wizard carries on to the name and the look.
+ const startQuick = async () => {
+ if (o.creating) return;
+ o.creating = true; ui.onbError = ''; render();
+ try {
+ clearSeedDrafts();
+ ui.passphrase = ''; ui.confirmPass = ''; ui.draftMnemonic = '';
+ ui.newWalletKind = 'spending';
+ await enterWallet(newMnemonic(), '', { generated: true, unseen: true, signup: true });
+ } catch (e) {
+ ui.onbError = e.message || String(e);
+ } finally {
+ ui.newWalletKind = null;
+ o.creating = false;
+ render();
+ }
+ };
const startCreate = () => {
ui.unlockTab = 'create';
ui.unlockError = '';
@@ -4443,11 +4505,16 @@ function onboardScreen() {
title(t('onbWelcomeTitle')),
// spaced as the sign-in list is (10px)
h('div', { class: 'col', style: 'gap:10px' },
- 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: startCreate,
- }, t('onbStart')),
+ quickSignupOk()
+ ? h('button', {
+ // a stable hook for tests, so this button survives its own copy
+ id: 'onb-create', 'data-quick': '1',
+ class: 'btn-primary btn-block', style: 'font-size:17px;padding:14px', disabled: !!o.creating, onClick: startQuick,
+ }, o.creating ? h('span', { class: 'spinner sm' }) : t('onbCreateAccount'))
+ : h('button', {
+ id: 'onb-create',
+ class: 'btn-primary btn-block', style: 'font-size:17px;padding:14px', onClick: startCreate,
+ }, t('onbStart')),
h('button', {
id: 'onb-signin',
class: 'btn-block', style: 'font-size:17px;padding:14px',
@@ -4572,14 +4639,18 @@ function onboardScreen() {
]);
}
if (o.step === 'spend') {
+ // A sign-up's name is the account itself — what people pay, follow and
+ // message — so it's claimed here, not skipped.
+ const signupAcc = activeAccount();
+ const signup = !o.fromGift && !!(signupAcc && signupAcc.signupAt && signupAcc.kind === 'spending');
return page([
- title(t(o.fromGift ? 'onbGiftNameTitle' : 'onbSpendTitle')),
- h('p', { class: 'muted', style: 'margin:0' }, t(o.fromGift ? 'onbGiftNameBody' : 'onbSpendBody')),
+ title(t(o.fromGift ? 'onbGiftNameTitle' : signup ? 'onbNameTitle' : 'onbSpendTitle')),
+ h('p', { class: 'muted', style: 'margin:0' }, t(o.fromGift ? 'onbGiftNameBody' : signup ? 'onbNameBody' : 'onbSpendBody')),
featureHook('namesClaimForm') || h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('onbNameWait'))),
// Optional means optional: one tap to the wallet, no questions — on
// Savings, the account that exists without any setup (or, after a
// gift, the account the gift arrived in).
- h('button', { class: 'btn-ghost btn-block', onClick: () => {
+ signup ? null : h('button', { class: 'btn-ghost btn-block', onClick: () => {
const home = o.home || 'savings';
ui.onb = null;
try { localStorage.removeItem(ONB_STEP_KEY); } catch {}
@@ -4737,7 +4808,8 @@ function onboardScreen() {
ui.tab = 'receive';
render();
} }, t('onbEnter')),
- h('button', { class: 'btn-ghost btn-block', onClick: () => { o.step = 'avatar'; render(); } }, t('back')),
+ // no Back: the name and the look are done, and both change anytime from
+ // the profile (Adam, 2026-11-15)
]);
}
@@ -4915,22 +4987,140 @@ wallet.registerCacheExtension({
},
});
+// ---- Savings, later --------------------------------------------------------
+// An account made by signing up is Spending alone (kind 'spending', marked
+// savingsLater): no on-chain face, no carousel, no Move money. Its owner adds
+// Savings from Settings → Wallet whenever they like — the same seed, account
+// 0, so it is the on-chain wallet that was always underneath. A balance must
+// never hide, so on-chain money or history arriving adds it by itself.
+function addSavings(acc, { quiet = false } = {}) {
+ if (!acc || acc.kind !== 'spending' || !acc.savingsLater) return false;
+ delete acc.kind; delete acc.savingsLater;
+ if (!acc.spendingSetup) acc.spendingSetup = Date.now();
+ acc.savingsAdded = Date.now();
+ persistAccounts();
+ if (acc.persisted) writeVault();
+ if (acc.id === activeId) { try { wallet.saveCache(); } catch {} }
+ if (!quiet) render();
+ return true;
+}
+function savingsEvident() {
+ const a = activeAccount();
+ if (!a || a.kind !== 'spending' || !a.savingsLater || !wallet.loaded) return;
+ if ((wallet.spendable || 0) > 0 || (wallet.pendingIncoming || 0) > 0 || (wallet.txs || []).length) addSavings(a, { quiet: true });
+}
+// The shape rides the synced snapshot, so the phone opens the account the
+// way the laptop has it: Spending-only until Savings is added on either.
+// Commutative: savingsAdded only ever turns on and wins over spendingOnly.
+wallet.registerCacheExtension({
+ mergeAlways: true,
+ save: () => {
+ const a = activeAccount();
+ if (!a) return {};
+ if (a.savingsAdded) return { savingsAdded: a.savingsAdded };
+ return a.kind === 'spending' && a.savingsLater ? { spendingOnly: true } : {};
+ },
+ load: (d) => {
+ const a = activeAccount();
+ if (!d || !a || a.type !== 'full' || a.deriveIndex) return;
+ if (d.savingsAdded) { if (addSavings(a, { quiet: true }) === false && !a.savingsAdded) { a.savingsAdded = d.savingsAdded; persistAccounts(); } return; }
+ if (d.spendingOnly && !a.kind && !a.savingsAdded && !(wallet.txs || []).length && !(wallet.spendable || 0)) {
+ a.kind = 'spending'; a.savingsLater = true;
+ persistAccounts();
+ }
+ },
+});
+
+// ---- ways back in ----------------------------------------------------------
+// The sign-in methods that can open this account somewhere else, besides its
+// recovery phrase: a passkey, a Google account, a nostr identity — each holds
+// a copy of the seed encrypted to its own key on the relays (see the
+// nostrlogin feature's addRecovery). Listed on the account so Settings can
+// say what's set up; the list rides the synced snapshot, union by key.
+function recoveryMethods(a = activeAccount()) {
+ return (a && Array.isArray(a.recovery)) ? a.recovery : [];
+}
+function sameSeed(a, x) {
+ return x.mnemonic && x.mnemonic === a.mnemonic && (x.passphrase || '') === (a.passphrase || '');
+}
+function mergeRecovery(list) {
+ const a = activeAccount();
+ if (!a || !a.mnemonic || !Array.isArray(list) || !list.length) return false;
+ let changed = false;
+ for (const x of accounts) {
+ if (x !== a && !sameSeed(a, x)) continue;
+ const have = new Map(recoveryMethods(x).map((m) => [m.id, m]));
+ for (const m of list) if (m && m.id && !have.has(m.id)) { have.set(m.id, m); changed = true; }
+ x.recovery = [...have.values()].sort((p, q) => (p.at || 0) - (q.at || 0));
+ }
+ if (changed) { persistAccounts(); if (a.persisted) writeVault(); }
+ return changed;
+}
+function addRecoveryMethod(m) {
+ if (!mergeRecovery([{ ...m, at: m.at || Date.now() }])) return;
+ try { wallet.saveCache(); } catch {}
+ render();
+}
+wallet.registerCacheExtension({
+ mergeAlways: true,
+ save: () => {
+ const a = activeAccount(), r = recoveryMethods();
+ return { ...(r.length ? { recovery: r } : {}), ...(a && a.waysBackPrompted ? { waysBackPrompted: a.waysBackPrompted } : {}) };
+ },
+ load: (d) => {
+ if (!d) return;
+ if (d.recovery) mergeRecovery(d.recovery);
+ // asked on another device: asked (only ever turns on)
+ const a = activeAccount();
+ if (d.waysBackPrompted && a && !a.waysBackPrompted) { a.waysBackPrompted = d.waysBackPrompted; persistAccounts(); }
+ },
+});
+
+// A sign-up is asked for a way back in ONCE, and only when there's something
+// to lose (Adam, 2026-11-15: "only prompt to add a way back in after there's
+// some balance and only if we haven't prompted before"). The ask is recorded
+// the moment it first shows — on the account, the directory and the synced
+// snapshot — and stays up for that visit until Add one or Not now.
+function waysBackPrompt(a) {
+ if (recoveryMethods(a).length) { if (ui.waysBackPromptFor === a.id) ui.waysBackPromptFor = null; return null; }
+ if (ui.waysBackPromptFor !== a.id) {
+ if (a.waysBackPrompted || !wallet.loaded) return null;
+ const bal = (featureHook('spendingSat') || 0) + (wallet.spendable || 0) + (wallet.pendingIncoming || 0);
+ if (bal <= 0) return null;
+ a.waysBackPrompted = Date.now();
+ persistAccounts();
+ try { wallet.saveCache(); } catch {}
+ ui.waysBackPromptFor = a.id;
+ }
+ const done = () => { ui.waysBackPromptFor = null; };
+ return h('div', { class: 'notice info col ways-back-prompt', style: 'margin:12px 0 0;gap:10px' },
+ h('span', {}, t('waysBackPrompt')),
+ h('div', { class: 'row gap6' },
+ h('button', { class: 'btn-sm btn-primary', onClick: () => { done(); ui.tab = 'settings'; ui.settingsPage = 'wallet'; render(); } }, t('waysBackReminderGo')),
+ h('button', { class: 'btn-sm btn-ghost', onClick: () => { done(); render(); } }, t('onbNotNow'))));
+}
+
// 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() {
+ savingsEvident();
const a = activeAccount();
if (!a || !a.mnemonic || a.seedSeen || wallet.watchOnly) return null;
+ // A sign-up never saw a phrase and wasn't asked to: what it needs is A way
+ // back in, any one — and once it has one, nothing to nag about.
+ const signup = !!a.signupAt;
+ if (signup) return waysBackPrompt(a);
return h('div', { class: 'small muted', style: 'margin:10px 0 0;text-align:center' },
- t('backupReminder'),
+ t(signup ? 'waysBackReminder' : 'backupReminder'),
' ',
h('button', { class: 'linklike small', onClick: () => {
ui.tab = 'settings';
ui.settingsPage = 'wallet';
- ui.revealShown = 'masked';
+ if (!signup) ui.revealShown = 'masked';
render();
- } }, t('backupReminderGo')));
+ } }, t(signup ? 'waysBackReminderGo' : 'backupReminderGo')));
}
// Safe mode (see BOOT_GUARD in build.js): the app crashed on open twice in a
@@ -5154,7 +5344,9 @@ function balanceCard() {
})
: null,
h('div', { class: 'small faint', style: 'text-transform:uppercase;letter-spacing:.06em' },
- kindLocked ? viewLabel(acc0, view)
+ // a lone balance needs no name: blank unless its owner gave it
+ // one (Adam, 2026-11-15); the two-face carousel keeps its labels
+ kindLocked ? ((acc0.viewLabels && acc0.viewLabels[view]) || '')
: hasSpending ? (viewSpending ? t('spendingLabel') : t('savingLabel')) : t('balance'))),
manageBtn(view)),
h('div', { class: 'amt', style: firstLoad ? 'opacity:.3' : '' },
@@ -5587,7 +5779,8 @@ function tabContent() {
if (ui.tab === 'send' && sendFocused) return pane;
return h('div', { class: 'col', style: 'gap:16px' },
pane,
- h('div', { class: 'small faint', style: 'text-transform:uppercase;letter-spacing:.06em;margin-top:4px' }, t('tabHistory')),
+ // no heading over nothing: it comes with the first payment
+ hist ? h('div', { class: 'small faint', style: 'text-transform:uppercase;letter-spacing:.06em;margin-top:4px' }, t('tabHistory')) : null,
hist);
}
@@ -5675,7 +5868,8 @@ function receiveTab() {
{ class: 'card col', style: 'align-items:center;gap:14px' },
h('div', { html: qrSvg(ui.arkRecvAddr) }),
h('div', { class: 'addr-box break', style: 'width:100%' }, ui.arkRecvAddr),
- copyBtn(ui.arkRecvAddr, t('copyAddress'))
+ copyBtn(ui.arkRecvAddr, t('copyAddress')),
+ featureHook('posAvailable') ? h('button', { class: 'btn-block', id: 'recv-amount', onClick: () => featureHook('openAmountRequest') }, t('recvAmount')) : null
);
}
}
@@ -6620,8 +6814,9 @@ function historyTab() {
wallet.historyLoading ? h('p', { class: 'small muted', style: 'margin:0' }, t('loadingHistory')) : null
);
const featEntries = featureAll('historyEntries', txs, histSel);
- if (!txs.length && !featEntries.length)
- return h('div', { class: 'card' }, h('p', { class: 'muted center', style: 'margin:0' }, t('noTxYet')));
+ // nothing yet: no card at all, it appears with the first payment
+ // (Adam, 2026-11-15: "let's not show an empty history section")
+ if (!txs.length && !featEntries.length) return null;
// Transactions and feature entries merge into one timeline.
const entries = [
@@ -6857,6 +7052,14 @@ const ctx = {
getAccount: () => accountSel(),
setAccount: (a, dir) => setAccountSel(a, dir),
offerProfileSetup, pickConfirm,
+ // the ways back in (Settings → Wallet): what's set up, and adding one
+ recoveryMethods: () => recoveryMethods(), addRecoveryMethod,
+ spendingOnlyAccount: () => { const a = activeAccount(); return !!(a && a.kind === 'spending' && a.savingsLater); },
+ seedSeen: () => !!(activeAccount() || {}).seedSeen,
+ showRecoveryPhrase: () => {
+ ui.tab = 'settings'; ui.settingsPage = 'wallet'; ui.revealShown = ui.revealShown || 'masked'; render();
+ requestAnimationFrame(() => { try { document.querySelector('.recovery-card')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch {} });
+ },
// Open (or create) a wallet from a mnemonic — used by nostr login.
openMnemonic: async (mnemonic, passphrase, opts) => enterWallet(mnemonic, passphrase, opts),
// Profile pictures retain the metadata-scrubbing image uploader. Public
diff --git a/src/features/messages.js b/src/features/messages.js
index 318d1cc..d5d286a 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -18104,7 +18104,7 @@ export function messagesFeature(ctx) {
// the message's own custom emoji (an edit brings its own set)
const em = emojiTagMap((edit && edit.author === m.author ? edit.rumor : m.rumor).tags);
return h(
- 'div', { class: 'chat-row' + (mine ? ' mine' : '') + (grouped ? ' grouped' : '') },
+ 'div', { class: 'chat-row group' + (mine ? ' mine' : '') + (grouped ? ' grouped' : '') },
grouped ? h('div', { class: 'chat-avatar spacer' }) : avatar(m.author),
h('div', { class: 'chat-body' },
grouped ? null : h('div', { class: 'chat-meta' },
@@ -18146,8 +18146,7 @@ export function messagesFeature(ctx) {
title: t('msgReact'),
onClick: (e) => { e.stopPropagation(); sendReaction(room, chId, m, emoji); },
}, reactNode(emoji), n > 1 ? ' ' + n : '')))
- : null)(zapChip(m.rumor.id, { cls: 'chat-react', onClick: canZapPk(m.author) ? () => zapMessage(m.author, m.rumor.id) : null, onHold: () => openZapSettings(m.author, m.rumor.id) }))),
- msgZapBtn(m.author, m.rumor.id)))
+ : null)(zapChip(m.rumor.id, { cls: 'chat-react', onClick: canZapPk(m.author) ? () => zapMessage(m.author, m.rumor.id) : null, onHold: () => openZapSettings(m.author, m.rumor.id) })))))
);
});
}
@@ -18767,8 +18766,7 @@ export function messagesFeature(ctx) {
ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id;
render();
}),
- }, dmQuote(m), ...(m.rumor.kind === 15 ? [] : noteBody(m.rumor.content, 0, emojiTagMap(m.rumor.tags))), ...attachmentNodes(m.rumor, m.wn), dmChips(m)),
- m.mine ? null : msgZapBtn(m.rumor.pubkey, m.rumor.id)),
+ }, dmQuote(m), ...(m.rumor.kind === 15 ? [] : noteBody(m.rumor.content, 0, emojiTagMap(m.rumor.tags))), ...attachmentNodes(m.rumor, m.wn), dmChips(m))),
h('div', { class: 'chat-time' }, timeLabel(m.rumor.created_at * 1000)))))
: [h('div', { class: 'muted small', style: 'text-align:center;padding:24px 0' }, t('msgNoDmsYet'))])),
dmReplyBar(),
diff --git a/src/features/names.js b/src/features/names.js
index 3f23a0c..81b64e6 100644
--- a/src/features/names.js
+++ b/src/features/names.js
@@ -683,6 +683,8 @@ export function namesFeature(ctx) {
onClick: () => copy(addr),
html: '<svg width="15" height="15" 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>',
}))),
+ // ask for a sum: the point of sale's amount pad and invoice, simplified
+ hook('posAvailable') ? h('button', { class: 'btn-block', id: 'recv-amount', onClick: () => hook('openAmountRequest') }, t('recvAmount')) : null,
moreSection());
}
diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js
index 4a811fc..6f700b1 100644
--- a/src/features/nostrlogin.js
+++ b/src/features/nostrlogin.js
@@ -158,6 +158,17 @@ export function nostrLoginFeature(ctx) {
// and the payment name too — the Receive pane should open with its QR
const nameHintP = ctx.hook('namesLookupByPk', signer.pubkey);
const res = await walletForSigner(signer);
+ // A way back in (added from Settings): the key holds a copy of an
+ // account's seed, and opens that account as ITSELF — its own name,
+ // profile and identity. Nothing about the key is adopted or kept.
+ if (res.mode === 'restored' && res.recovery) {
+ try { if (signer.kind === 'bunker' && signer.close) signer.close(); } catch {}
+ ctx.onbNostrLogin(true);
+ ui.navAnimSkip = true;
+ await ctx.openMnemonic(res.mnemonic, res.passphrase || '', { spendingHint: !!res.spending, spendingOnly: !!res.spendingOnly });
+ busy(false);
+ return;
+ }
if (res.mode === 'new') {
// No wallet on this identity yet — make one and walk straight in.
// The interstitial that used to ask first read as an error screen to
@@ -681,6 +692,124 @@ export function nostrLoginFeature(ctx) {
resumeLogin().then((s) => { if (s) render(); }).catch(() => {});
}
+ // ---- ways back in ------------------------------------------------------
+ // Settings → Wallet: the sign-in methods that can open THIS account on
+ // another device, added after the fact — a passkey, Google, a nostr key —
+ // next to the recovery phrase. Each one gets a copy of the seed encrypted
+ // to its own key (kind 30078, marked `recovery`), so signing in with it
+ // anywhere opens this account as itself. Unlike linking a login, the
+ // account's identity doesn't change.
+ async function addRecovery(makeSigner, via, label = '') {
+ ui.waysBackError = ''; ui.waysBackBusy = via; ui.nostrLoginBusy = true; render();
+ let signer = null;
+ try {
+ if (!wallet.mnemonic) throw new Error(t('nlNeedSeed'));
+ signer = await makeSigner();
+ const st = load();
+ if (st.pubkey && st.pubkey === signer.pubkey) throw new Error(t('waysBackIsLogin'));
+ // a key that already opens a DIFFERENT wallet keeps it: never overwrite
+ const existing = await fetchWalletBackup(signer);
+ if (existing && existing.mnemonic
+ && (existing.mnemonic !== wallet.mnemonic || (existing.passphrase || '') !== (wallet.passphrase || ''))) {
+ throw new Error(t('waysBackTaken'));
+ }
+ await publishWalletBackup(signer, {
+ mnemonic: wallet.mnemonic, passphrase: wallet.passphrase || '',
+ spending: true, recovery: true, spendingOnly: !!ctx.spendingOnlyAccount(),
+ });
+ ctx.addRecoveryMethod({ via, id: signer.pubkey, label });
+ ui.waysBackNostr = false;
+ toast(t('waysBackAdded'));
+ } catch (e) {
+ const msg = String((e && e.message) || e || '');
+ // a dismissed passkey or Google dialog is a plain cancel
+ if (!/NotAllowed|abort/i.test(String((e && e.name) || '') + ' ' + msg)) ui.waysBackError = msg;
+ } finally {
+ try { if (signer && signer.kind === 'bunker' && signer.close) signer.close(); } catch {}
+ ui.waysBackBusy = null; ui.nostrLoginBusy = false;
+ render();
+ }
+ }
+ async function passkeyRecovery() {
+ const pass = await import('../passkey.js');
+ const name = ctx.hook('namesAddress') || 'coinos';
+ await addRecovery(async () => keySigner(await pass.passkeyCreate({ name, displayName: name })), 'passkey');
+ }
+ async function googleRecovery() {
+ // opened in the click itself (user activation) — see googleLogin
+ const popup = window.open('about:blank', 'OAuth', 'width=600,height=600');
+ ui.waysBackError = ''; ui.waysBackBusy = 'google'; render();
+ try {
+ const pome = await import('../pomegranate.js');
+ const { uri, email } = await pome.loginWithGoogle(popup, {});
+ await addRecovery(() => bunkerSigner(uri, { onAuth: (url) => { ui.nostrLoginAuthUrl = url; render(); } }), 'google', email || '');
+ } catch (e) {
+ try { popup && popup.close(); } catch {}
+ ui.waysBackError = (e && e.message) || String(e);
+ ui.waysBackBusy = null;
+ render();
+ }
+ }
+ // pen over a sheet of paper, in the line style of the passkey mark
+ const PAPER_MARK = '<svg width="19" height="19" style="display:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><path d="M13 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5"/><path d="M8 9h4"/><path d="M8 13h3"/><path d="M8 17h6"/><path d="M18.4 2.6a2 2 0 0 1 2.8 2.8L14 12.6l-3.6.9.9-3.6z"/></svg>';
+ const fmtDay = (ms) => { try { return new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); } catch { return ''; } };
+ function waysBackCard() {
+ if (wallet.watchOnly || !wallet.mnemonic) return null;
+ const methods = ctx.recoveryMethods() || [];
+ const st = load();
+ const busyVia = ui.waysBackBusy;
+ const hasPasskey = typeof window !== 'undefined' && !!window.PublicKeyCredential && !!navigator.credentials;
+ const done = (mark, title, detail, key) => h('div', { class: 'row gap6 ways-back-row', 'data-way': key, style: 'align-items:center;padding:8px 2px' },
+ h('span', { style: 'display:flex;flex-shrink:0;width:23px;justify-content:center', html: mark }),
+ h('span', { class: 'col', style: 'flex:1;gap:1px;min-width:0' },
+ h('span', { style: 'font-weight:600' }, title),
+ detail ? h('span', { class: 'small muted break' }, detail) : null),
+ h('span', { class: 'ways-back-check', 'aria-label': t('waysBackOn') }, '✓'));
+ const add = (mark, label, via, onClick) => h('button', {
+ class: 'btn-block ways-back-add', 'data-way': via, disabled: !!busyVia, onClick,
+ }, markedLabel(mark, label, busyVia === via));
+ const rows = [];
+ // the sign-in this account came through is a way back in already
+ if (st.pubkey) {
+ const via = st.via || 'nostr';
+ rows.push(done(via === 'google' ? GOOGLE_MARK : via === 'passkey' ? PASSKEY_MARK : OSTRICH,
+ t('waysBackLogin', { via: via === 'google' ? 'Google' : via === 'passkey' ? t('doorPasskey') : 'Nostr' }),
+ via === 'google' || via === 'passkey' ? '' : (npubOf(st.pubkey) || '').slice(0, 20) + '…', 'login'));
+ }
+ for (const m of methods) {
+ if (m.via === 'passkey') rows.push(done(PASSKEY_MARK, t('doorPasskey'), t('waysBackAddedOn', { date: fmtDay(m.at) }), 'passkey'));
+ else if (m.via === 'google') rows.push(done(GOOGLE_MARK, 'Google', m.label || t('waysBackAddedOn', { date: fmtDay(m.at) }), 'google'));
+ else rows.push(done(OSTRICH, 'Nostr', (npubOf(m.id) || '').slice(0, 20) + '…', 'nostr'));
+ }
+ const seen = ctx.seedSeen();
+ const have = (via) => methods.some((m) => m.via === via) || (via !== 'nostr' && st.via === via);
+ const adds = [
+ hasPasskey && !have('passkey') ? add(PASSKEY_MARK, t('waysBackAddPasskey'), 'passkey', () => passkeyRecovery()) : null,
+ !have('google') ? add(GOOGLE_MARK, t('waysBackAddGoogle'), 'google', () => googleRecovery()) : null,
+ ui.waysBackNostr
+ ? h('div', { class: 'col', style: 'gap:8px' },
+ h('div', { class: 'row between' },
+ h('strong', { class: 'small' }, t('waysBackAddNostr')),
+ h('span', { class: 'linklike small', onClick: () => { ui.waysBackNostr = false; ui.waysBackError = ''; render(); } }, t('cancel'))),
+ signerButtons((make) => addRecovery(make, 'nostr'), { nostrOnly: true }))
+ : add(OSTRICH, t('waysBackAddNostr'), 'nostr', () => { ui.waysBackNostr = true; ui.waysBackError = ''; ui.nostrLoginError = ''; render(); }),
+ // the recovery phrase, as one more button (Adam, 2026-11-15): it opens
+ // the masked words below, ticked once they've been seen
+ h('button', { class: 'btn-block ways-back-add', 'data-way': 'phrase', disabled: !!busyVia, onClick: () => ctx.showRecoveryPhrase() },
+ h('span', { style: 'display:flex;align-items:center;justify-content:center;gap:8px' },
+ h('span', { style: 'display:flex;flex-shrink:0', html: PAPER_MARK }),
+ t('waysBackPaper'),
+ seen ? h('span', { class: 'ways-back-check', 'aria-label': t('waysBackOn') }, '✓') : null)),
+ ].filter(Boolean);
+ return h('div', { class: 'card col ways-back', style: 'gap:6px' },
+ h('h3', {}, t('waysBackTitle')),
+ h('p', { class: 'small muted', style: 'margin:0 0 4px' }, t('waysBackDesc')),
+ h('div', { class: 'col', style: 'gap:0' }, ...rows),
+ adds.length ? h('div', { class: 'col', style: 'gap:8px;margin-top:6px' }, ...adds) : null,
+ ui.waysBackError ? h('div', { class: 'notice err' }, ui.waysBackError) : null,
+ h('p', { class: 'small faint', style: 'margin:4px 0 0' }, t('waysBackNote')));
+ }
+
function settingsCard() {
if (wallet.watchOnly || !wallet.mnemonic) return null;
const st = load();
@@ -879,5 +1008,6 @@ export function nostrLoginFeature(ctx) {
].filter(Boolean);
},
nostrSettingsCards() { return [settingsCard()]; },
+ waysBackCard() { return waysBackCard(); },
};
}
diff --git a/src/features/pos.js b/src/features/pos.js
index 3727862..eb4b0e9 100644
--- a/src/features/pos.js
+++ b/src/features/pos.js
@@ -44,8 +44,11 @@ export function posFeature(ctx) {
// ---- flow ----------------------------------------------------------------
// ui.pos: { step: 'amount'|'tip'|'invoice'|'paid', amount, note, tipMode, tipCustom, sale, error, busy }
- function open() {
- ui.pos = { step: 'amount', amount: '', note: '', tipMode: null, tipCustom: '', sale: null, error: '' };
+ // `request`: Receive → Amount (Adam, 2026-11-15) — the same amount pad
+ // and invoice for asking someone for a sum, without the till around it:
+ // no tip prompt, no tip settings or day's sales, and Back is Receive.
+ function open(opts = {}) {
+ ui.pos = { step: 'amount', amount: '', note: '', tipMode: null, tipCustom: '', sale: null, error: '', request: !!opts.request };
render();
}
// Digits on screen, into the same field a keyboard or a paste would fill.
@@ -90,7 +93,7 @@ export function posFeature(ctx) {
if (!bill) { p.error = t('posEnterAmount'); render(); return; }
p.error = '';
if (!hook('arkReady')) { p.error = t('posNeedsSpending'); render(); return; }
- if (st().askTip) { p.step = 'tip'; p.tipMode = null; p.tipCustom = ''; render(); return; }
+ if (st().askTip && !p.request) { p.step = 'tip'; p.tipMode = null; p.tipCustom = ''; render(); return; }
makeSale(bill, 0, 0);
}
function tipSat() {
@@ -107,6 +110,7 @@ export function posFeature(ctx) {
id: 's' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
at: Date.now(), billSat: bill, tipSat: tip, tipPct, note: (p.note || '').trim().slice(0, 80),
status: 'open', fiat: rateNow() ? { code: getCurrency(), rate: rateNow() } : null,
+ ...(p.request ? { request: true } : {}),
};
try {
const memo = (sale.note ? sale.note + ' · ' : '') + t('posMemo', { bill: fmtAmount(bill) + ' ' + unitLabel(), tip: tip ? fmtAmount(tip) + ' ' + unitLabel() : '0' });
@@ -145,7 +149,7 @@ export function posFeature(ctx) {
function newSale() {
const p = ui.pos;
if (p.sale && p.sale.status === 'open') voidSale(p.sale);
- Object.assign(p, { step: 'amount', amount: '', note: '', tipMode: null, tipCustom: '', sale: null, error: '' });
+ Object.assign(p, { step: 'amount', amount: '', note: '', tipMode: null, tipCustom: '', sale: null, error: '', request: p.request });
render();
}
@@ -194,7 +198,7 @@ export function posFeature(ctx) {
return h('div', { class: 'col', style: 'gap:16px' },
ctx.brandHeader(false),
h('div', { class: 'card col', style: 'gap:12px' },
- h('h3', { style: 'margin:0' }, t('posTitle')),
+ h('h3', { style: 'margin:0' }, t(p.request ? 'recvAmountTitle' : 'posTitle')),
h('div', { class: 'input-group' },
h('input', {
type: 'text', inputmode: 'decimal', class: 'pos-amount', placeholder: '0', value: p.amount,
@@ -204,12 +208,12 @@ export function posFeature(ctx) {
unitTag()),
getUnit() !== 'fiat' && billSat() && fiatLine(billSat()) ? h('div', { class: 'small muted', style: 'text-align:right' }, fiatLine(billSat())) : null,
numpad(() => p.amount, (v) => { p.amount = v; }),
- h('input', { type: 'text', placeholder: t('posNoteHint'), value: p.note, maxlength: '80', onInput: (e) => { p.note = e.target.value; } }),
+ h('input', { type: 'text', placeholder: t(p.request ? 'recvAmountNote' : 'posNoteHint'), value: p.note, maxlength: '80', onInput: (e) => { p.note = e.target.value; } }),
p.error ? h('div', { class: 'notice error small' }, p.error) : null,
- h('button', { class: 'btn-primary btn-block', disabled: !!p.busy, onClick: charge }, p.busy ? h('span', { class: 'spinner sm' }) : t('posCharge')),
+ h('button', { class: 'btn-primary btn-block', id: 'pos-charge', disabled: !!p.busy, onClick: charge }, p.busy ? h('span', { class: 'spinner sm' }) : t(p.request ? 'recvAmountGo' : 'posCharge')),
p.busy ? h('div', { class: 'small muted', style: 'text-align:center' }, t('posMakingInvoice')) : null),
- tipSettingsCard(s),
- salesCard(s),
+ p.request ? null : tipSettingsCard(s),
+ p.request ? null : salesCard(s),
back());
}
function tipSettingsCard(s) {
@@ -231,7 +235,7 @@ export function posFeature(ctx) {
}
function salesCard(s) {
adoptPaid(s);
- const sales = [...s.sales].reverse();
+ const sales = s.sales.filter((x) => !x.request).reverse(); // Receive → Amount isn't a sale
if (!sales.length) return null;
const today = dayKey(Date.now());
const paidToday = sales.filter((x) => x.status === 'paid' && dayKey(x.paidAt || x.at) === today);
@@ -310,8 +314,8 @@ export function posFeature(ctx) {
h('h3', { style: 'margin:0' }, t('posPaid')),
money(sale.billSat + sale.tipSat, sale.fiat && sale.fiat.rate),
sale.tipSat ? h('div', { class: 'small muted' }, t('posBreakdown', { bill: fmtAmount(sale.billSat), tip: fmtAmount(sale.tipSat), u: unitLabel() })) : null,
- h('button', { class: 'btn-primary btn-block', onClick: newSale }, t('posNewSale'))),
- back());
+ p.request ? null : h('button', { class: 'btn-primary btn-block', onClick: newSale }, t('posNewSale'))),
+ p.request ? h('button', { class: 'btn-primary btn-block', onClick: close }, t('recvAmountDone')) : back());
}
function posScreen() {
const p = ui.pos;
@@ -346,6 +350,8 @@ export function posFeature(ctx) {
h('p', { class: 'small faint', style: 'margin:0' }, t('posLinkHint', { url })))];
},
openPos: open,
+ openAmountRequest: () => open({ request: true }),
+ posAvailable: () => !!hook('arkReady'),
// The history detail of a till payment says what was the bill and what the tip.
arkMoveDetailExtra(m, row) {
const sale = saleOfMovement(m);
diff --git a/src/i18n.js b/src/i18n.js
index 3e42daa..bb82ed2 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -1498,6 +1498,31 @@ const STR = {
onbContinue: 'Continue',
onbSkipAvatar: 'Continue',
onbDoneTitle: "You're all set!",
+ onbCreateAccount: 'Create an account',
+ onbNameTitle: 'Claim your name',
+ onbNameBody: "It's your username and your payment address: people pay you, follow you and message you with just your name.",
+ waysBackReminder: 'Add a way back into your account, in case you lose this device.',
+ waysBackPrompt: 'You have bitcoin in here now. Add a way back into your account, in case you lose this device.',
+ waysBackReminderGo: 'Add one',
+ addSavings: 'Add a savings account',
+ addSavingsDesc: 'On-chain bitcoin, for keeping. Same account, a second balance.',
+ savingsAddedToast: 'Savings added',
+ waysBackTitle: 'Ways back in',
+ waysBackDesc: 'Any one of these opens this account on a new device, or if this one is lost.',
+ waysBackNote: 'Each one can open your account and spend its money, so only add ones you control.',
+ waysBackOn: 'Set up',
+ waysBackLogin: 'Signed in with {via}',
+ waysBackAddedOn: 'Added {date}',
+ waysBackPhraseSeen: 'Seen. Keep it written down somewhere safe',
+ waysBackPhraseUnseen: 'Twelve words that open this account in any wallet',
+ waysBackShow: 'Show',
+ waysBackAddPasskey: 'Passkey auth',
+ waysBackAddGoogle: 'Google auth',
+ waysBackAddNostr: 'Nostr auth',
+ waysBackPaper: 'Pen and paper',
+ waysBackAdded: 'Added. You can sign in with it now',
+ waysBackIsLogin: 'That is the account you signed in with; it already opens this one.',
+ waysBackTaken: 'That sign-in already opens a different coinos account, so it can’t open this one too.',
onbDoneBody: 'Your spending account is set up. Your address is live and the coinos community chat is waiting for you.',
onbEnter: 'Enter coinos',
spendSetup: 'Set up a spending account',
@@ -1652,6 +1677,13 @@ const STR = {
settingsNostrDesc: 'Identity and connections',
settingsNotifDesc: 'What buzzes this device',
settingsAdvancedDesc: 'Network, rarities, rescues',
+ aboutTitle: 'About',
+ recvAmount: 'Amount',
+ recvAmountTitle: 'Request an amount',
+ recvAmountNote: 'What it’s for (optional)',
+ recvAmountGo: 'Create invoice',
+ recvAmountDone: 'Done',
+ settingsAboutDesc: 'How it works, support, app, theme',
settingsPosDesc: 'Till mode, tips and the /pos link',
unlockForLabel: 'Stay unlocked',
unlockFor5m: '5 minutes',
diff --git a/src/nostr-login.js b/src/nostr-login.js
index 3357f61..562a5d4 100644
--- a/src/nostr-login.js
+++ b/src/nostr-login.js
@@ -514,8 +514,14 @@ export async function fetchWalletBackup(signer, relays = BACKUP_RELAYS) {
// `spending` rides along as a hint: a restored wallet paints its Spending
// side from the first frame instead of reflowing when the name lookup proves
// it seconds later.
-export async function publishWalletBackup(signer, { mnemonic, passphrase, spending }, relays = BACKUP_RELAYS) {
- const content = await signer.encryptSelf(JSON.stringify({ mnemonic, passphrase: passphrase || '', ...(spending ? { spending: true } : {}) }));
+// `recovery` marks a copy kept by a WAY BACK IN (a passkey, Google or nostr
+// key added from Settings): signing in with that key opens the account as
+// itself — its own identity, name and profile — rather than adopting the
+// key as the account's nostr identity the way a login does. `spendingOnly`
+// carries a sign-up's shape (no Savings side yet) to the next device.
+export async function publishWalletBackup(signer, { mnemonic, passphrase, spending, recovery, spendingOnly }, relays = BACKUP_RELAYS) {
+ const content = await signer.encryptSelf(JSON.stringify({ mnemonic, passphrase: passphrase || '',
+ ...(spending ? { spending: true } : {}), ...(recovery ? { recovery: true } : {}), ...(spendingOnly ? { spendingOnly: true } : {}) }));
const evt = await signer.signEvent({
kind: BACKUP_KIND, created_at: Math.floor(Date.now() / 1000),
tags: [['d', SEED_DTAG]], content,
@@ -537,7 +543,7 @@ export async function publishWalletBackup(signer, { mnemonic, passphrase, spendi
// deriving (key in hand) or generating (signer only).
export async function walletForSigner(signer) {
const found = await fetchWalletBackup(signer);
- if (found) return { mnemonic: found.mnemonic, passphrase: found.passphrase, spending: !!found.spending, mode: 'restored' };
+ if (found) return { mnemonic: found.mnemonic, passphrase: found.passphrase, spending: !!found.spending, recovery: !!found.recovery, spendingOnly: !!found.spendingOnly, mode: 'restored' };
if (signer.secret) {
// Publishing the derived seed costs nothing: the derivation is public,
// so anyone holding this key could compute it anyway. It buys the thing
diff --git a/src/passkey.js b/src/passkey.js
index 248e1e6..7d5e6e5 100644
--- a/src/passkey.js
+++ b/src/passkey.js
@@ -54,14 +54,16 @@ export async function passkeySignIn() {
// Create a passkey, then derive the key from it. Some platforms evaluate PRF
// during creation; the rest need a follow-up assertion against the new
// credential — both paths end at the same bytes.
-export async function passkeyCreate() {
+// `name` labels the credential in the platform's passkey picker (a way back
+// into a named account reads "[email protected]", not just "coinos").
+export async function passkeyCreate({ name = 'coinos', displayName = 'Coinos wallet' } = {}) {
const cred = await navigator.credentials.create({
publicKey: {
challenge: crypto.getRandomValues(new Uint8Array(32)),
rp: { name: 'Coinos', id: location.hostname },
user: {
id: crypto.getRandomValues(new Uint8Array(16)),
- name: 'coinos', displayName: 'Coinos wallet',
+ name, displayName,
},
pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }],
authenticatorSelection: { residentKey: 'required', userVerification: 'required' },
diff --git a/src/style.css b/src/style.css
index 17412b2..2bbf8fc 100644
--- a/src/style.css
+++ b/src/style.css
@@ -534,6 +534,13 @@ html:has(#app .chat-card), body:has(#app .chat-card) { overflow: hidden; }
}
.chat-row { display: flex; gap: 9px; align-items: flex-start; }
.chat-row.grouped { margin-top: -6px; }
+/* group chats: the avatar sits beside the name and the bubble starts at the
+ left edge under both, instead of indented a whole avatar column */
+.chat-row.group { display: grid; grid-template-columns: 30px minmax(0, 1fr); column-gap: 9px; row-gap: 3px; align-items: center; }
+.chat-row.group > .chat-body { display: contents; }
+.chat-row.group .chat-meta { grid-column: 2; margin-bottom: 0; }
+.chat-row.group .chat-line { grid-column: 1 / -1; }
+.chat-row.group > .chat-avatar.spacer { display: none; }
.chat-avatar {
width: 30px; height: 30px; border-radius: 50%; flex-shrink: 0; object-fit: cover;
background: var(--accent-soft);
@@ -623,7 +630,8 @@ html:has(#app .chat-card), body:has(#app .chat-card) { overflow: hidden; }
.chat-react.on { border-color: color-mix(in srgb, currentColor 55%, transparent); font-weight: 600; }
/* zap tally: a little bolt + sats, amber on both themes; .on = we zapped it */
.zap-tally { display: inline-flex; align-items: center; gap: 2px; font-size: 11px; font-weight: 650; color: #e6a800; white-space: nowrap; flex-shrink: 0; }
-.zap-tally.chat-react { font-size: 12px; padding: 1px 7px 1px 5px; }
+/* in a bubble the zap total is just the amber bolt + sats: no pill, no ring */
+.zap-tally.chat-react { font-size: 12px; padding: 1px 2px 1px 0; background: none; border: none; }
.zap-tally.clickable { cursor: pointer; }
/* the one-tap ⚡ beside someone else's chat bubble */
.chat-line { display: flex; align-items: center; gap: 4px; }
@@ -2171,3 +2179,20 @@ html:has(.call-overlay:not(.call-min)) { overflow: hidden; scrollbar-gutter: aut
/* on: the accent with its own text colour (accent-fg). accent-ink, used
before, is light on dark mode's white accent: unreadable (Adam, 2026-11-15) */
.moment-toggle.on { color: var(--accent-fg); background: var(--accent); border: 1px solid var(--accent); }
+
+/* Settings → Wallet: the ways back into an account */
+.ways-back .ways-back-row + .ways-back-row { border-top: 1px solid var(--line); }
+.ways-back .ways-back-check { color: var(--green, #2e9d5b); font-weight: 700; font-size: 16px; padding: 0 4px; }
+
+/* Settings → Wallet → Balance settings rows */
+.balance-settings-row { display: flex; align-items: center; gap: 14px; width: 100%; text-align: left;
+ padding: 14px 16px; border: 1px solid var(--line); border-radius: 14px; background: transparent; color: inherit; font: inherit; cursor: pointer; }
+@media (hover: hover) { .balance-settings-row:hover { background: rgba(127,127,127,.06); } }
+.balance-settings-row .bs-tile { display: flex; align-items: center; justify-content: center; flex-shrink: 0;
+ width: 38px; height: 38px; border-radius: 11px; }
+.balance-settings-row .bs-tile.spend { color: #7c3aed; background: rgba(124, 58, 237, .14); }
+.balance-settings-row .bs-tile.save { color: #f7931a; background: rgba(247, 147, 26, .14); }
+.balance-settings-row .bs-text { flex: 1; gap: 3px; min-width: 0; }
+.balance-settings-row .bs-title { font-weight: 600; }
+.balance-settings-row .bs-tail { color: var(--muted); font-size: 20px; line-height: 1; flex-shrink: 0; width: 14px; text-align: center; }
+.settings-prefs select { flex: 1 1 0; min-width: 0; }
diff --git a/tools/balance-settings-row-test.js b/tools/balance-settings-row-test.js
index 89e68d9..38d34b3 100644
--- a/tools/balance-settings-row-test.js
+++ b/tools/balance-settings-row-test.js
@@ -2,7 +2,9 @@
// balance card's gear opens. "I simply cannot find this option" (Nils,
// 2026-11-12) — the Spending gear was the only way in.
// Run: bun tools/balance-settings-row-test.js (NO_BUILD=1 to reuse dist/)
-import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
+import puppeteer from './puppeteer.js';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let ok = true;
const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
@@ -32,13 +34,16 @@ try {
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'mainnet'));
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
- await click('create a new wallet'); await sleep(600);
- await seedWords(page);
- await skipSeedCheck(page);
+ // on mainnet Create makes a Spending-only account (signup-quick-test); a
+ // seed wallet with Savings alone comes in through the seed-phrase door
+ await page.waitForSelector('#onb-signin'); await page.click('#onb-signin'); await sleep(400);
+ await page.click('#onb-restore'); await sleep(400);
+ await page.waitForSelector('textarea'); await page.type('textarea', generateMnemonic(wordlist)); await click('open wallet');
if (await waitText('claim a username', 8000)) { await click('not now'); await sleep(800); }
await waitText('receive');
await sleep(1500);
check('savings-only wallet lists Savings alone', await (async () => { await openWalletPage(); const v = await page.$$eval('.balance-settings-row', (l) => l.map((e) => e.dataset.view)); await page.goBack(); await page.goBack(); await sleep(500); return v.join() === 'savings'; })());
+ await waitText('set up a spending account', 30000);
await click('set up a spending account'); await sleep(800);
if (await waitText('claim a username', 5000)) { await click('not now'); await sleep(800); }
await waitText('receive'); await sleep(800);
diff --git a/tools/chat-layout-test.js b/tools/chat-layout-test.js
index 7c79858..a0f00ad 100644
--- a/tools/chat-layout-test.js
+++ b/tools/chat-layout-test.js
@@ -164,7 +164,7 @@ try {
console.log('\n[direct messages]');
await page.click('.chat-back');
await sleep(500);
- check('footer returns when the conversation closes', await page.$eval('.footer', (el) => getComputedStyle(el).display !== 'none'));
+ // (no footer to bring back: with a wallet open the pickers live in Settings, 2026-11-15)
await click('button', 'New message');
await setInput('input[placeholder*="npub" i]', '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798');
await click('button', 'Open');
diff --git a/tools/footer-support-test.js b/tools/footer-support-test.js
index a37c070..0de6c9a 100644
--- a/tools/footer-support-test.js
+++ b/tools/footer-support-test.js
@@ -1,5 +1,6 @@
-// The footer's "Show your support" link opens the hat shop, from any page,
-// and only shows once a wallet is open (the shop pays from it).
+// "Show your support" lives on Settings → About (2026-11-15: the footer is
+// gone once a wallet is open; language and currency top the Settings hub) and opens the hat shop; it shows only
+// once a wallet is open (the shop pays from it).
// Run: bun tools/footer-support-test.js
import puppeteer from './puppeteer.js';
import { generateMnemonic } from '@scure/bip39';
@@ -18,31 +19,43 @@ const browser = await puppeteer.launch({ executablePath: process.env.CHROME || '
const page = await browser.newPage();
const errs = []; page.on('pageerror', (e) => errs.push(String(e)));
const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
-const link = () => page.evaluate(() => [...document.querySelectorAll('.footer button')].some((b) => b.textContent.trim() === 'Show your support'));
+const link = () => page.evaluate(() => !!document.getElementById('about-support'));
+const footerOnly = () => page.evaluate(() => { const f = document.querySelector('.footer'); return f ? { selects: f.querySelectorAll('select').length, other: f.querySelectorAll('button, a').length, text: f.innerText.replace(/\s+/g, ' ').trim() } : null; });
+const openAbout = async () => {
+ await page.evaluate(() => [...document.querySelectorAll('button')].find((e) => e.getAttribute('aria-label') === 'Settings').click()); await sleep(500);
+ await page.evaluate(() => [...document.querySelectorAll('.settings-tile')].find((e) => /^About/.test(e.textContent.trim())).click()); await sleep(500);
+};
try {
await page.setViewport({ width: 390, height: 844 });
await page.goto(server.url.origin, { waitUntil: 'domcontentloaded' });
await sleep(600);
check('no link before there is a wallet', !(await link()));
- await click('create a new wallet'); await sleep(500); await click('to import'); await sleep(400);
+ await page.waitForSelector('#onb-signin'); await page.click('#onb-signin'); await sleep(400);
+ await page.click('#onb-restore'); await sleep(400);
await page.waitForSelector('textarea'); await page.type('textarea', generateMnemonic(wordlist)); await click('open wallet');
for (let i = 0; i < 80 && !(await page.evaluate(() => [...document.querySelectorAll('button')].some((b) => b.textContent.trim() === 'Send'))); i++) {
await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => /^(not now|skip|continue|later)$/i.test(b.textContent.trim()))?.click());
await sleep(400);
}
await sleep(1500);
- check('the footer has "Show your support"', await link());
- // from another page: the feed
- await page.evaluate(() => [...document.querySelectorAll('nav button, .bottom-nav button')].find((b) => /feed/i.test(b.textContent))?.click()); await sleep(800);
- await page.evaluate(() => [...document.querySelectorAll('.footer button')].find((b) => b.textContent.trim() === 'Show your support').click());
+ const f = await footerOnly();
+ check('with a wallet open there is no footer', !f || (await page.$eval('.footer', (el) => getComputedStyle(el).display === 'none')), JSON.stringify(f));
+ await page.evaluate(() => [...document.querySelectorAll('button')].find((e) => e.getAttribute('aria-label') === 'Settings').click()); await sleep(500);
+ check('Settings carries the language and currency', await page.evaluate(() => document.querySelectorAll('.settings-prefs select').length) === 2);
+ if (process.env.SHOT2) await page.screenshot({ path: process.env.SHOT2 });
+ await page.evaluate(() => [...document.querySelectorAll('.settings-tile')].find((e) => /^About/.test(e.textContent.trim())).click()); await sleep(500);
+ const about = await page.evaluate(() => document.querySelector('.about-card')?.innerText || '');
+ check('About has the credits, How it works, the theme and the commit', /Adam Soltys/.test(about) && /git\.coinos\.io/.test(about) && /How it works/.test(about) && /(Dark|Light) mode/.test(about) && /Running git commit [0-9a-f]{7}/.test(about), about.replace(/\n/g, ' | '));
+ check('...and "Show your support"', await link());
+ const th = await page.evaluate(() => document.documentElement.dataset.theme);
+ await page.click('#about-theme'); await sleep(300);
+ check('the theme switch works from there', await page.evaluate(() => document.documentElement.dataset.theme) !== th);
+ await page.click('#about-theme'); await sleep(300);
+ if (process.env.SHOT) await page.screenshot({ path: process.env.SHOT });
+ await page.click('#about-support');
await sleep(1200);
const txt = await page.evaluate(() => document.body.innerText);
- check('it opens the hat shop', /hat/i.test(txt) && !!(await page.evaluate(() => document.querySelector('.hat-btn') || /Crown|Top hat|hats?/i.test(document.querySelector('.card')?.innerText || ''))), txt.slice(0, 160).replace(/\n/g, ' | '));
- await page.evaluate(() => { document.querySelector('.bottom-nav, nav')?.remove(); window.scrollTo(0, 1e6); });
- await sleep(300);
- if (process.env.SHOT) await (await page.$('.footer')).screenshot({ path: process.env.SHOT });
- else await page.screenshot({ path: '/tmp/footer-support.png', fullPage: true });
- // || '/tmp/footer-support.png', fullPage: true });
+ check('it opens the hat shop', /hat/i.test(txt), txt.slice(0, 160).replace(/\n/g, ' | '));
check('no page errors', errs.length === 0, errs.join('; ').slice(0, 300));
} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(); }
console.log(ok ? 'PASS' : 'FAIL');
diff --git a/tools/onboard-backup-test.js b/tools/onboard-backup-test.js
index 8fc1071..946f342 100644
--- a/tools/onboard-backup-test.js
+++ b/tools/onboard-backup-test.js
@@ -64,6 +64,10 @@ try {
const page = await browser.newPage();
await page.setViewport({ width: 390, height: 844 });
await page.goto('http://localhost:5291/', { waitUntil: 'domcontentloaded' });
+ // regtest: the classic phrase flow. Where a name can be claimed (mainnet,
+ // mutinynet) Create is the quick sign-up instead — signup-quick-test.js.
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(1500);
// Two choices and nothing else (2026-11-06): a new wallet, or one you
// already have — the sign-in methods wait behind the second.
diff --git a/tools/pos-test.js b/tools/pos-test.js
index 55349cb..2b6a0b7 100644
--- a/tools/pos-test.js
+++ b/tools/pos-test.js
@@ -30,7 +30,7 @@ const ctx = {
};
const feature = posFeature(ctx); window.__feature = feature;
function render() { document.querySelector('#app').replaceChildren(feature.screenView() || h('div', { id: 'closed' }, 'closed')); }
-window.test = { open: () => { feature.openPos(); }, init: () => { feature.init(); render(); },
+window.test = { open: () => { feature.openPos(); }, request: () => { feature.openAmountRequest(); }, init: () => { feature.init(); render(); },
cards: () => { document.querySelector('#app').replaceChildren(...feature.posSettingsCards()); }, settle: (ok) => { const w = watch; watch = null; w.cb({ step: ok ? 'done' : 'failed' }); }, get watch() { return watch && watch.id; }, get cancelled() { return cancelled; }, get sales() { return (state.pos || {}).sales || []; }, ui };
render();
`;
@@ -128,6 +128,24 @@ try {
check("the old device-wide flag is dropped on start (it can't say whose it was)", await page.evaluate(() => (test.ui.pos = null, true)) && (await boot('owner')) && await page.evaluate(() => localStorage.getItem('btc-wallet-pos-mode') === null));
check('another wallet on the same device does not start in point of sale', !(await boot('someone-else')));
check('the owner still does, after anyone else has been in', await boot('owner'));
+
+ console.log('\n[Receive → Amount]');
+ await page.evaluate(() => { localStorage.removeItem('btc-wallet-pos-mode:owner'); test.ui.pos = null; test.request(); }); await pause(50);
+ let rq = await text();
+ check('opens as Request an amount, without the till around it', /Request an amount/.test(rq) && !/ask for a tip|Tips today|Sales today/i.test(rq) && !(await page.$('.pos-sale')), rq.slice(0, 200));
+ const before = await page.evaluate(() => test.sales.length);
+ await type('.pos-amount', '2100');
+ await page.click('#pos-charge'); await pause(100);
+ rq = await text();
+ check('straight to the invoice, no tip prompt', /Scan to pay/i.test(rq) && /2,100/.test(rq) && !!(await page.$('.pos-qr')), rq.slice(0, 200));
+ const req = await page.evaluate(() => test.sales[test.sales.length - 1]);
+ check('the request is for the amount, no tip, and marked', req.billSat === 2100 && req.tipSat === 0 && req.request === true, JSON.stringify(req));
+ await page.evaluate(() => test.settle(true)); await pause(100);
+ check('paid, with Done back to Receive', /Done/.test(await text()) && !/New sale/.test(await text()));
+ await click('Done'); await pause(50);
+ check('Done closes it', !!(await page.$('#closed')));
+ await page.evaluate(() => { test.ui.pos = null; test.open(); }); await pause(50);
+ check("the till's sales list leaves requests out", !(await text()).includes('2,100'), String(before));
check('no browser errors', errors.length === 0);
} finally { await browser.close(); server.stop(true); }
console.log('\n✅ point of sale');
diff --git a/tools/send-lnaddr-advance-test.js b/tools/send-lnaddr-advance-test.js
index fd6c1a1..14640ca 100644
--- a/tools/send-lnaddr-advance-test.js
+++ b/tools/send-lnaddr-advance-test.js
@@ -43,7 +43,8 @@ try {
await page.setViewport({ width: 390, height: 844 });
await page.goto(server.url.origin, { waitUntil: 'domcontentloaded' });
await sleep(400);
- await click('create a new wallet'); await sleep(500); await click('to import'); await sleep(400);
+ await page.waitForSelector('#onb-signin'); await page.click('#onb-signin'); await sleep(400);
+ await page.click('#onb-restore'); await sleep(400);
await page.waitForSelector('textarea'); await page.type('textarea', generateMnemonic(wordlist)); await click('open wallet');
// mainnet onboarding: skip the username step (and anything after it)
for (let i = 0; i < 80 && !(await page.evaluate(() => [...document.querySelectorAll('button')].some((b) => b.textContent.trim() === 'Send'))); i++) {
diff --git a/tools/signup-quick-test.js b/tools/signup-quick-test.js
new file mode 100644
index 0000000..a269faf
--- /dev/null
+++ b/tools/signup-quick-test.js
@@ -0,0 +1,203 @@
+// Signing up is a name and a look, nothing else (Adam, 2026-11-15): "i'd like
+// to move away from requiring people to backup and verify a seed phrase to
+// sign up. we should just have them claim a name and pick their avatar/cover
+// and they should be in. we should also not create a savings account by
+// default but let them add it later … adding additional auth methods like
+// passkey, nostr, or google should be possible once they've created their
+// account."
+//
+// Mainnet, with the Ark server's info read for real and the names registrar
+// mocked (nothing is claimed): Create an account → no twelve words, no check
+// → Claim your name (no "Not now") → Choose your look → in, on a Spending-only
+// wallet (no carousel, no Savings face); the wallet line asks for a way back
+// in; Settings → Wallet shows "Ways back in" (passkey via a CDP virtual
+// authenticator → listed, the line goes away) and "Add a savings account"
+// (→ the Savings face appears); a reload keeps both.
+// Run: bun tools/signup-quick-test.js (NO_BUILD=1 to reuse dist/)
+import puppeteer from './puppeteer.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed');
+const server = Bun.serve({ port: 0, fetch: async (req) => {
+ const path = new URL(req.url).pathname;
+ const f = Bun.file('dist' + path);
+ return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html'));
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+const errs = [];
+const registered = [];
+const cors = { 'access-control-allow-origin': '*', 'access-control-allow-headers': 'content-type,authorization', 'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS' };
+await page.setRequestInterception(true);
+page.on('request', (r) => {
+ const u = new URL(r.url());
+ if (u.origin === server.url.origin || u.hostname === 'ark.coinos.io') return r.continue();
+ if (u.hostname !== 'names.coinos.io') return r.abort();
+ if (r.method() === 'OPTIONS') return r.respond({ status: 200, headers: cors, body: '{}' });
+ const reply = (j, status = 200) => r.respond({ status, headers: { ...cors, 'content-type': 'application/json' }, body: JSON.stringify(j) });
+ if (u.pathname === '/register' && r.method() === 'POST') { const j = JSON.parse(r.postData() || '{}'); registered.push(j.name); return reply({ ok: true, name: j.name }); }
+ if (u.pathname.startsWith('/name/')) return reply({ taken: false });
+ if (u.pathname.startsWith('/pubkey/')) return reply({});
+ return reply({});
+});
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (x) => page.evaluate((s) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(s)); if (e) e.click(); return !!e; }, x);
+const text = () => page.evaluate(() => document.body.innerText);
+const waitText = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await text()).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const openWalletPage = async () => {
+ await page.evaluate(() => [...document.querySelectorAll('button')].find((e) => e.getAttribute('aria-label') === 'Settings').click()); await sleep(500);
+ await page.evaluate(() => [...document.querySelectorAll('.settings-tile')].find((e) => /^Wallet/.test(e.textContent.trim())).click()); await sleep(600);
+};
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ const cdp = await page.createCDPSession();
+ await cdp.send('WebAuthn.enable');
+ await cdp.send('WebAuthn.addVirtualAuthenticator', { options: {
+ protocol: 'ctap2', transport: 'internal', hasResidentKey: true, hasUserVerification: true, isUserVerified: true, hasPrf: true, automaticPresenceSimulation: true } });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'mainnet'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('#onb-create', { timeout: 15000 });
+ check('the front door says Create an account', (await page.$eval('#onb-create', (e) => e.textContent.trim())) === 'Create an account');
+ await page.click('#onb-create');
+ check('no seed screens: straight to Claim your name', await waitText('claim your name', 20000));
+ const t1 = await text();
+ check('no twelve words or check on the way', !(await page.$('.words')) && !/which word/i.test(t1));
+ check('no "Not now" on a sign-up\'s name step', !/not now/i.test(t1));
+ await page.waitForSelector('.onb input[type=text]', { timeout: 20000 });
+ await page.type('.onb input[type=text]', 'quicktester');
+ await click('claim');
+ check('the name goes to the registrar', await (async () => { for (let i = 0; i < 80 && !registered.length; i++) await sleep(250); return registered.includes('quicktester'); })(), registered.join());
+ check('then the look: avatar and cover', await (async () => { for (let i = 0; i < 80; i++) { if (await page.$('.onb-cover')) return true; await sleep(250); } return false; })());
+ await click('continue'); await sleep(800);
+ if (await waitText("you're all set", 8000)) await click('enter');
+ await page.waitForSelector('.balance-face', { timeout: 20000 });
+ await sleep(1500);
+ const shape = async () => page.evaluate(() => ({
+ faces: document.querySelectorAll('.balance-face').length,
+ carousel: !!document.querySelector('.bal-carousel'),
+ label: document.querySelector('.balance-face .small.faint')?.textContent.trim(),
+ body: document.body.innerText,
+ }));
+ let s = await shape();
+ check('in, on Spending alone (no Savings face, no name on it)', s.faces === 1 && !s.carousel && !s.label, JSON.stringify({ faces: s.faces, carousel: s.carousel, label: s.label }));
+ // asked once, and only with money in the account (2026-11-15): not on an empty one
+ check('no way-back-in prompt on an empty account', !/add a way back into your account/i.test(s.body) && !(await page.$('.ways-back-prompt')));
+ // money arrives in Spending (the ark feature, found on the heap, made to
+ // report a balance; then a repaint)
+ const giveBalance = async () => {
+ const protoH = await page.evaluateHandle(() => Object.prototype);
+ const all = await page.queryObjects(protoH);
+ const found = await page.evaluate((objs) => {
+ const f = objs.find((o) => { try { return o && o.id === 'ark' && typeof o.spendingSat === 'function'; } catch { return false; } });
+ if (!f) return false;
+ f.spendingSat = () => 5000;
+ window.dispatchEvent(new Event('resize'));
+ document.querySelector('.tabs button')?.click(); document.querySelector('.tabs button')?.click();
+ return true;
+ }, all);
+ await all.dispose(); await protoH.dispose();
+ await sleep(800);
+ return found;
+ };
+ check('(test: found the ark feature)', await giveBalance());
+ check('with a balance, it asks once for a way back in', !!(await page.$('.ways-back-prompt')), (await text()).slice(0, 300).replace(/\n/g, ' | '));
+ await page.evaluate(() => [...document.querySelectorAll('.ways-back-prompt button')].find((b) => /not now/i.test(b.textContent)).click()); await sleep(400);
+ check('Not now puts it away', !(await page.$('.ways-back-prompt')));
+ await page.reload({ waitUntil: 'domcontentloaded' }); await page.waitForSelector('.balance-face', { timeout: 20000 }); await sleep(1500);
+ await giveBalance();
+ check('...and never again, not after a reload', !(await page.$('.ways-back-prompt')));
+ for (let i = 0; i < 60 && !(await page.$('#recv-amount')); i++) await sleep(250);
+ check('Receive has an Amount button', !!(await page.$('#recv-amount')));
+ check('no History heading on an empty wallet', !/\bHISTORY\b/.test(await page.evaluate(() => document.body.innerText)));
+ if (process.env.SHOT3) await page.screenshot({ path: process.env.SHOT3 });
+ await page.click('#recv-amount'); await sleep(500);
+ check('Amount opens the amount pad', !!(await page.$('.pos-amount')) && /request an amount/i.test(await text()));
+ if (process.env.SHOT4) await page.screenshot({ path: process.env.SHOT4 });
+ await click('back'); await sleep(500);
+ check('Back returns to Receive', !(await page.$('.pos-amount')) && !!(await page.$('#recv-amount')));
+ const acct = await page.evaluate(() => JSON.parse(sessionStorage.getItem('btc-wallet-accounts') || '{}'));
+ const a0 = (acct.accounts || [])[0] || {};
+ check('the account: spending-only, phrase unseen, saved to the device', a0.kind === 'spending' && a0.savingsLater && a0.seedSeen === false && a0.persisted, JSON.stringify({ kind: a0.kind, later: a0.savingsLater, seen: a0.seedSeen, persisted: a0.persisted }));
+
+ await openWalletPage();
+ check('Settings → Wallet opens with Ways back in', !!(await page.$('.ways-back')) && /ways back in/i.test(await text()));
+ const views = await page.$$eval('.balance-settings-row[data-view]', (l) => l.map((e) => e.dataset.view));
+ check('the ways back in read as auth buttons', (await page.$$eval('.ways-back-add', (l) => l.map((e) => e.textContent.trim()))).join('|') === 'Passkey auth|Google auth|Nostr auth|Pen and paper', (await page.$$eval('.ways-back-add', (l) => l.map((e) => e.textContent.trim()))).join('|'));
+ check('only Spending is listed, with Add a savings account', views.join() === 'spending' && !!(await page.$('#add-savings')), views.join());
+ if (process.env.SHOT2) { await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark')); await page.evaluate(() => document.querySelector('.balance-settings-row').closest('.card').scrollIntoView()); await sleep(300); await page.screenshot({ path: process.env.SHOT2 }); await page.evaluate(() => document.documentElement.removeAttribute('data-theme')); }
+ if (process.env.SHOT) { await page.evaluate(() => document.querySelector('.ways-back').scrollIntoView()); await page.screenshot({ path: process.env.SHOT }); }
+ // a passkey: the relays are stubbed, so the publish fails offline — the
+ // error says so and nothing is listed
+ await page.evaluate(() => document.querySelector('.ways-back-add[data-way="passkey"]')?.click());
+ await sleep(4000);
+ const pk = await page.evaluate(() => ({ rows: [...document.querySelectorAll('.ways-back-row')].map((e) => e.dataset.way), err: document.querySelector('.ways-back .notice.err')?.textContent || '' }));
+ check('adding a passkey lists it', pk.rows.includes('passkey') && !pk.err, JSON.stringify(pk));
+ const backups = await page.evaluate(() => (window.__relayGuard.events || []).filter((e) => e.kind === 30078 && e.tags.some((t) => t[0] === 'd' && t[1] === 'coinos:wallet:v1')));
+ // the list and the reminder, as the app sees them (record a method directly)
+ check('the phrase card waits behind Pen and paper', !(await page.$('.recovery-card')));
+ await page.evaluate(() => document.querySelector('.ways-back-add[data-way="phrase"]')?.click());
+ await sleep(500);
+ check('Pen and paper opens the recovery phrase card', !!(await page.$('.recovery-card .words')));
+
+ await page.evaluate(() => document.getElementById('add-savings')?.click()); await sleep(800);
+ const views2 = await page.$$eval('.balance-settings-row[data-view]', (l) => l.map((e) => e.dataset.view));
+ check('Add a savings account adds Savings beside Spending', views2.join() === 'spending,savings' && !(await page.$('#add-savings')), views2.join());
+ await page.goBack(); await page.goBack(); await sleep(800);
+ s = await shape();
+ check('the wallet now has both faces', s.carousel && s.faces === 2, JSON.stringify({ faces: s.faces, carousel: s.carousel }));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('.balance-face', { timeout: 20000 }); await sleep(1500);
+ s = await shape();
+ check('a reload keeps Savings', s.carousel && s.faces === 2);
+
+ // A new device: everything here wiped, the relays holding only what the
+ // passkey published. Sign in → Passkey opens THIS account, as itself.
+ const before = await page.evaluate(() => {
+ const st = JSON.parse(sessionStorage.getItem('btc-wallet-accounts') || '{}');
+ const a = (st.accounts || [])[0] || {};
+ return { mnemonic: a.mnemonic, nostrPk: a.nostrPk };
+ });
+ before.backups = backups;
+ check('the passkey published one encrypted seed copy', before.backups.length >= 1 && !before.backups[0].content.includes(before.mnemonic.split(' ')[0] + ' '), String(before.backups.length));
+ const backup = before.backups[before.backups.length - 1];
+ await page.evaluateOnNewDocument((ev) => {
+ const send = WebSocket.prototype.send;
+ WebSocket.prototype.send = function (d) {
+ try {
+ const m = JSON.parse(d);
+ if (m[0] === 'REQ' && m.slice(2).some((f) => (f.kinds || []).includes(30078) && (f.authors || []).includes(ev.pubkey))) {
+ const ws = this;
+ setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], ev]) })), 1);
+ }
+ } catch {}
+ return send.call(this, d);
+ };
+ }, backup);
+ await page.evaluate(async () => {
+ localStorage.clear(); sessionStorage.clear();
+ for (const d of (await indexedDB.databases?.() || [])) if (d.name) indexedDB.deleteDatabase(d.name);
+ localStorage.setItem('btc-wallet-network', 'mainnet');
+ });
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('#onb-signin', { timeout: 15000 });
+ await page.click('#onb-signin'); await sleep(500);
+ await click('passkey');
+ for (let i = 0; i < 120 && !(await page.$('.balance-face')); i++) await sleep(250);
+ if (!(await page.$('.balance-face'))) console.log((await text()).slice(0, 800), errs);
+ const after = await page.evaluate(() => {
+ const st = JSON.parse(sessionStorage.getItem('btc-wallet-accounts') || '{}');
+ const a = (st.accounts || [])[0] || {};
+ const login = Object.keys(localStorage).concat([]).filter((k) => /:nostrlogin$/.test(k)).map((k) => localStorage.getItem(k));
+ return { mnemonic: a.mnemonic, nostrPk: a.nostrPk, kind: a.kind, login, onb: localStorage.getItem('btc-wallet-onb-step') };
+ });
+ check('the passkey opened the same account', after.mnemonic === before.mnemonic);
+ check('as itself: same identity, the passkey key not adopted', after.nostrPk === before.nostrPk && !after.login.some((v) => v && v.includes(backup.pubkey)), JSON.stringify({ was: before.nostrPk?.slice(0, 8), now: after.nostrPk?.slice(0, 8) }));
+ check('no wizard on the way back in', !after.onb && !(await page.$('.onb')));
+ check('no page errors', errs.length === 0, errs.join(' | '));
+} finally {
+ await browser.close(); server.stop(true);
+}
+console.log(ok ? 'PASS' : 'FAIL');
+process.exit(ok ? 0 : 1);