diff options
| author | Adam Soltys <[email protected]> | 2026-10-02 09:26:56 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-02 09:26:56 -0700 |
| commit | fe569ffa701e3f9b54330def31193217eb470ab9 (patch) | |
| tree | a13f33180c53346f49369f4bd52d03bfa38005e6 | |
| parent | 65aa6c93153b28554127627dde2883a0685eeaff (diff) | |
| download | coinosv3-fe569ffa701e3f9b54330def31193217eb470ab9.tar.gz coinosv3-fe569ffa701e3f9b54330def31193217eb470ab9.zip | |
Balance card gear replaces Accounts; Spending's coins live behind it
- The "Accounts" pill on each balance face is now a gear. It opens that
face's settings: Spending leads with its coins (renewal, the
co-operative move to Savings, unilateral exit), then Security (lock,
change password), the recovery phrase, auto-logout; Savings adds its
public key. A wallet with no nostr identity also gets sign-in and
log-out there, since it has no profile page to hold them.
- The exit cost is no longer said under the balance; the Spending
settings state it, always, not only when coin chains run deep.
- The lone Manage link under the balance is gone (the gear is the way
in); warning notices that are about the coins link to the same page.
- The Accounts list, Add wallet and Delete all are no longer reached
from the wallet (Delete all stays behind profile > Log out > forget).
- Tests follow the gear: padlock-ask, lock-wallet and account-access
pass again; cooperative-exit-ui clicks the Spending gear.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Dmcrt9KyNDKVWFKnb8BREo
| -rw-r--r-- | src/app.js | 95 | ||||
| -rw-r--r-- | src/features/ark.js | 56 | ||||
| -rw-r--r-- | src/i18n.js | 2 | ||||
| -rw-r--r-- | src/locales/ar.json | 4 | ||||
| -rw-r--r-- | src/locales/bn.json | 4 | ||||
| -rw-r--r-- | src/locales/de.json | 4 | ||||
| -rw-r--r-- | src/locales/es.json | 4 | ||||
| -rw-r--r-- | src/locales/fr.json | 4 | ||||
| -rw-r--r-- | src/locales/hi.json | 4 | ||||
| -rw-r--r-- | src/locales/id.json | 4 | ||||
| -rw-r--r-- | src/locales/ja.json | 4 | ||||
| -rw-r--r-- | src/locales/ko.json | 4 | ||||
| -rw-r--r-- | src/locales/mr.json | 4 | ||||
| -rw-r--r-- | src/locales/pt.json | 4 | ||||
| -rw-r--r-- | src/locales/ru.json | 4 | ||||
| -rw-r--r-- | src/locales/sw.json | 4 | ||||
| -rw-r--r-- | src/locales/ta.json | 4 | ||||
| -rw-r--r-- | src/locales/te.json | 4 | ||||
| -rw-r--r-- | src/locales/tr.json | 4 | ||||
| -rw-r--r-- | src/locales/ur.json | 4 | ||||
| -rw-r--r-- | src/locales/vi.json | 4 | ||||
| -rw-r--r-- | src/locales/zh.json | 4 | ||||
| -rw-r--r-- | src/style.css | 2 | ||||
| -rw-r--r-- | tools/account-access-test.js | 25 | ||||
| -rw-r--r-- | tools/ark/cooperative-exit-ui-test.js | 27 | ||||
| -rw-r--r-- | tools/lock-wallet-test.js | 16 | ||||
| -rw-r--r-- | tools/padlock-ask-test.js | 12 |
27 files changed, 216 insertions, 95 deletions
@@ -2515,9 +2515,11 @@ function lock({ offerPassword = false } = {}) { // ================================================================ WALLET // The avatar IS your profile now — one tap opens it (edit lives there). // Settings and Lock stand beside it as their own icons; no popup menu. +// The nostr identity the header avatar stands for, when there is one. +const headerIdentity = () => (featureHook('nostrLoginIdentity') || {}).pubkey || (wallet.nostrPubkey && wallet.nostrPubkey()) + || (activeAccount() || {}).nostrPk || null; function avatarMenu() { - const me = (featureHook('nostrLoginIdentity') || {}).pubkey || (wallet.nostrPubkey && wallet.nostrPubkey()) - || (activeAccount() || {}).nostrPk; + const me = headerIdentity(); // Older watch-only accounts may have no remembered Nostr identity. Keep // account access available even when there is no profile to open. const node = (me && featureHook('headerAvatar', me)) @@ -2527,13 +2529,11 @@ function avatarMenu() { }); return h('button', { class: 'header-avatar', - title: me ? t('profYourProfile') : t('accounts'), - 'aria-label': me ? t('profYourProfile') : t('accounts'), + title: me ? t('profYourProfile') : t('walletSettings'), + 'aria-label': me ? t('profYourProfile') : t('walletSettings'), onClick: () => { clearFeatureNav(); ui.screen = 'wallet'; - if (!me || !featureHook('showProfile', me)) { - ui.screen = 'accounts'; - } + if (!me || !featureHook('showProfile', me)) { openWalletSettings(); return; } render(); }, }, node); @@ -3089,17 +3089,18 @@ function claimTargets() { // Account switcher: pick a wallet, add another, or lock the session. +function logoutConfirmScreen() { + return h('div', { class: 'col', style: 'gap:16px' }, + brandHeader(false), + h('div', { class: 'card col' }, + h('h3', {}, t('logout') + '?'), + h('p', { class: 'small muted' }, t('logoutPopBlurb')), + h('button', { class: 'btn-primary btn-block', onClick: () => { ui.logoutConfirm = null; ctx.logout(); } }, t('logout')), + h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.logoutConfirm = null; render(); } }, t('back')))); +} function accountsScreen() { if (ui.pw) return h('div', { class: 'col', style: 'gap:16px' }, brandHeader(false), pwPromptCard()); - if (ui.logoutConfirm) { - return h('div', { class: 'col', style: 'gap:16px' }, - brandHeader(false), - h('div', { class: 'card col' }, - h('h3', {}, t('logout') + '?'), - h('p', { class: 'small muted' }, t('logoutPopBlurb')), - h('button', { class: 'btn-primary btn-block', onClick: () => { ui.logoutConfirm = null; ctx.logout(); } }, t('logout')), - h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.logoutConfirm = null; render(); } }, t('back')))); - } + if (ui.logoutConfirm) return logoutConfirmScreen(); if (ui.addWallet) { const aw = ui.addWallet; const fulls = accounts.filter((x) => x.type === 'full' && x.mnemonic); @@ -3387,20 +3388,53 @@ function autolockCard(a) { ); } -// Per-wallet settings screen — reached from Accounts → ⚙ for any account -// (not just the active one; full accounts keep their seed in memory). +// The balance card's gear: one face's settings. Spending leads with its +// coins (renewal, the cooperative move, a unilateral exit and what it costs); +// both carry the lock, the password and the recovery phrase that used to sit +// on the Accounts page. +function openWalletSettings(view) { + clearFeatureNav(); + ui.settingsId = activeId; + ui.settingsView = view || accountSel(); + ui.editLabel = null; ui.revealShown = false; ui.pubkeyShown = false; ui.loadSeed = null; + ui.screen = 'accountSettings'; + render(); + try { window.scrollTo(0, 0); } catch {} +} +function securityCard() { + if (!activeAccount()) return null; + // With a nostr identity, signing in elsewhere and logging out live on your + // profile; a wallet without one (an old watch-only) has no profile, so + // they live here. + const anon = !headerIdentity(); + return h('div', { class: 'card col', style: 'gap:8px' }, + h('h3', {}, t('securityTitle')), + lockBtn(), + hasVault() ? h('button', { class: 'btn-ghost btn-block', onClick: startChangePw }, t('changePassword')) : null, + anon ? h('button', { class: 'btn-block', onClick: signInAnother }, t('signInAnother')) : null, + anon ? h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.logoutConfirm = true; render(); } }, t('logout')) : null); +} function accountSettingsScreen() { + if (ui.pw) return h('div', { class: 'col', style: 'gap:16px' }, brandHeader(false), pwPromptCard()); + if (ui.logoutConfirm) return logoutConfirmScreen(); const a = accounts.find((x) => x.id === ui.settingsId) || activeAccount(); if (!a) { ui.screen = 'accounts'; return accountsScreen(); } + const view = ui.settingsView || accountSel(); + const spending = view === 'spending' && a.id === activeId; + const leave = () => { ui.editLabel = null; ui.revealShown = false; ui.pubkeyShown = false; ui.loadSeed = null; goBack(() => { ui.screen = 'wallet'; }); }; return h( 'div', { class: 'col', style: 'gap:16px' }, - brandHeader(false), + brandHeader(!!activeAccount()), + h('div', { class: 'row gap6', style: 'align-items:center;margin:2px 0 -4px 4px' }, + h('span', { style: 'display:flex;color:var(--muted)', html: GEAR_ICON(20) }), + h('h3', { style: 'margin:0' }, viewLabel(a, view))), + spending ? featureHook('spendingSettings') : null, + securityCard(), recoveryCard(a), - walletNameCard(a), - pubkeyCard(a), + view === 'spending' ? null : pubkeyCard(a), autolockCard(a), - h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.editLabel = null; ui.revealShown = false; ui.pubkeyShown = false; ui.loadSeed = null; goBack(() => { ui.screen = 'accounts'; }); } }, t('back')) + h('button', { class: 'btn-ghost btn-block', onClick: leave }, t('back')) ); } @@ -4153,6 +4187,7 @@ function setAccountSel(a, dir) { render(); } +const GEAR_ICON = (n) => `<svg width="${n}" height="${n}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="display:block"><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-2.83 2.83l-.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-4 0v-.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-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 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h.09a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.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 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v.09a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`; const SWAP_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block"><path d="M8 3 4 7l4 4"/><path d="M4 7h16"/><path d="m16 21 4-4-4-4"/><path d="M20 17H4"/></svg>'; function balanceCard() { @@ -4185,12 +4220,13 @@ function balanceCard() { // peeks at the edge and drags/snaps into place (native scroll-snap), the // way wallet cards behave in BlueWallet. Snapping IS the account switch. // Single-purpose and savings-only wallets keep the lone face. - const manageBtn = () => h('button', { - class: 'balance-switch', - onClick: () => { ui.screen = 'accounts'; render(); }, - }, - h('span', { html: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block"><rect x="2" y="6" width="20" height="14" rx="2"/><path d="M2 10h20"/><circle cx="17" cy="15" r="1.3"/></svg>' }), - h('span', { class: 'bsw-label' }, t('manageAccounts'))); + // Each face's own settings: Spending's coins and exits, the seed, the lock. + const manageBtn = (view) => h('button', { + class: 'balance-switch balance-gear', + title: t('walletSettings'), 'aria-label': t('walletSettings'), + onClick: () => openWalletSettings(view), + html: GEAR_ICON(17), + }); const faceFor = (view) => { const viewSpending = view === 'spending'; return h('div', { class: 'balance-face' }, @@ -4208,7 +4244,7 @@ function balanceCard() { h('div', { class: 'small faint', style: 'text-transform:uppercase;letter-spacing:.06em' }, kindLocked ? viewLabel(acc0, view) : hasSpending ? (viewSpending ? t('spendingLabel') : t('savingLabel')) : t('balance'))), - manageBtn()), + manageBtn(view)), h('div', { class: 'amt', style: firstLoad ? 'opacity:.3' : '' }, firstLoad ? h('span', { class: 'spinner sm', style: 'margin-right:8px' }) : null, animatedAmount('bal:' + view, viewSpending ? spending : saving), ' ', unitTag('unit')), @@ -5714,6 +5750,7 @@ async function importSnapshotFile(e) { // only invoked at runtime (first render happens after loadLocale below). const ctx = { h, ui, render, wallet, toast, copy, copyBtn, pasteBtn, blankSend, goBack, canGoBack, goHome, openExternal, showSend, + openWalletSettings: (view) => openWalletSettings(view), fmtAmount, unitLabel, unitTag, getUnit: () => unit, toggleUnit, download, // in fiat the typed figure is money, so it needs today's price to become sats parseAmount: (v, u) => parseAmount(v, u, rateNow()), diff --git a/src/features/ark.js b/src/features/ark.js index 87bb5c9..16786ec 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -1364,7 +1364,7 @@ export function arkFeature(ctx) { // A renewal in flight, beside the in-progress exits: the tap opens the // coins page, where the round's progress is spelled out. function arkRefreshActionItem(a) { - return h('div', { class: 'item', style: 'cursor:pointer', onClick: () => { ui.arkCoinsPage = true; render(); } }, + return h('div', { class: 'item', style: 'cursor:pointer', onClick: () => ctx.openWalletSettings('spending') }, h('div', { class: 'ico' }, '\u21bb'), h('div', { class: 'grow' }, h('div', { class: 'row gap6', style: 'align-items:center' }, @@ -2190,7 +2190,7 @@ export function arkFeature(ctx) { // The screen behind that notice's Manage link: every spendable coin with // its expiry and arkoor depth, what an uncooperative exit costs today, and // what a renewal costs and buys. - function arkCoinsPage() { + function arkCoinsPage({ embedded = false } = {}) { // The notice that opens this page reads the persisted state; so must the // page, or a tap before the manager has connected (the first seconds // after boot) found no coins and closed itself — Manage needed two taps. @@ -2206,8 +2206,13 @@ export function arkFeature(ctx) { const inRound = new Set(renewing.flatMap((a) => a.inputIds || [])); const pendingCoins = ((st && st.vtxos) || []).filter((v) => v.state === 'pending' && inRound.has(v.id)); const tip = (mgr && mgr._tipH) || 0; - const back = () => ctx.goBack(() => { ui.arkCoinsPage = null; ui.arkCoinsSel = null; }); // the page is a history entry now - if (!spend.length && !pendingCoins.length) { ui.arkCoinsPage = null; return null; } // nothing left to manage + const back = embedded + ? () => { ui.arkCoinsSel = null; ctx.goHome(); } + : () => ctx.goBack(() => { ui.arkCoinsPage = null; ui.arkCoinsSel = null; }); // the page is a history entry now + if (!spend.length && !pendingCoins.length) { + if (embedded) return h('div', { class: 'card col', style: 'gap:6px' }, h('h3', { style: 'margin:0' }, t('arkCoinsTitle')), h('p', { class: 'small muted', style: 'margin:0' }, t('arkCoinsNone'))); + ui.arkCoinsPage = null; return null; // nothing left to manage + } if (!mgr) connectArk().then(() => render()).catch(() => {}); const totalSat = spend.reduce((n, v) => n + v.amountSat, 0); const heldSat = totalSat + pendingCoins.reduce((n, v) => n + v.amountSat, 0); @@ -2325,9 +2330,18 @@ export function arkFeature(ctx) { } }, ui.arkBusy === 'exit' ? h('span', { class: 'spinner sm' }) : t('arkExitConfirmGo'))), h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.arkExitConfirm = null; render(); } }, t('cancel'))); } + // what a unilateral exit costs, which used to sit under the balance (and + // only when the coins' chains ran deep): here it is always said + let depth = null; + if (embedded) { + const cached = wallet.loadFeatureState('arkDepth', null); + const fee = exitFee || (cached && cached.exitFee) || 0; + if (fee > 0) depth = t('arkDepthNotice', { fee: fmtSats(fee) }); + } return h('div', { class: 'col', style: 'gap:16px' }, h('div', { class: 'card col', style: 'gap:8px' }, h('h3', { style: 'margin:0' }, t('arkCoinsTitle')), + depth ? h('div', { class: 'notice info small' }, depth) : null, h('p', { class: 'small muted', style: 'margin:0' }, !spend.length ? t('arkCoinsIntroRenewing', { total: fmtSats(heldSat) + ' sats' }) @@ -2414,7 +2428,7 @@ export function arkFeature(ctx) { h('p', { class: 'small muted', style: 'margin:0' }, t('arkCoinsExitDesc', { fee: fmtSats(exitFee), after: fmtSats(afterFee) })), h('button', { class: 'btn-ghost btn-block', disabled: !!ui.arkBusy || !spend.length || !mgr, onClick: () => { ui.arkExitConfirm = true; render(); } }, t('arkCoinsExitBtn'))), - h('button', { class: 'btn-ghost btn-block', onClick: back }, t('back'))); + embedded ? null : h('button', { class: 'btn-ghost btn-block', onClick: back }, t('back'))); } // What each server charges and promises, from its own published info — @@ -2946,7 +2960,7 @@ export function arkFeature(ctx) { h('div', { class: 'row gap6' }, h('button', { class: 'btn-ghost grow', onClick: () => { ui.arkOffboardSend = null; ui.arkError = ''; ui.send = blankSend(); - if (o.fromCoins) ctx.goBack(() => { ui.arkCoinsPage = true; }); else render(); + if (o.fromCoins) ctx.goBack(() => ctx.openWalletSettings('spending')); else render(); } }, t('back')), ui.arkBusy === 'offboard' ? h('button', { class: 'btn-primary grow', disabled: true }, h('span', { class: 'spinner sm' })) @@ -3480,6 +3494,11 @@ export function arkFeature(ctx) { new Promise((r) => setTimeout(r, 800)), ]); }, + // the Spending face's settings page leads with its coins + spendingSettings() { + if (wallet.watchOnly) return null; // a locked wallet can't renew or exit + return arkCoinsPage({ embedded: true }); + }, // the wallet's own pages keep the top nav: these are pages, not takeovers screenView() { const page = ui.arkCoinsPage ? arkCoinsPage() : ui.arkExitPage ? arkExitPage() : null; @@ -3933,15 +3952,14 @@ export function arkFeature(ctx) { }); else if (expiredSat > 0) lines.push({ text: t('arkExpiredDust', { amount: fmtSats(expiredSat) + ' sats' }), manage: true }); } - const depthNotice = exitDepthNotice(); - if (depthNotice) lines.push({ text: depthNotice, manage: true }); + // What a unilateral exit would cost is the Spending settings page's + // business (the gear on the card), not a line under the balance. // An upcoming renewal that will COST something — stated before it // happens. Ark is new to nearly everyone, and a fee nobody announced // reads as money gone missing (it did, once). Free renewals — the // normal case now — pass without comment, and one line under the - // balance is enough: the depth advisory outranks this forecast, and - // Manage carries the full story either way. - if (!depthNotice && ark && ark.state && ark._tipH && !wallet.watchOnly) { + // balance is enough: the settings page carries the full story. + if (ark && ark.state && ark._tipH && !wallet.watchOnly) { const spend = (ark.state.vtxos || []).filter((v) => v.state === 'spendable' && v.expiryHeight && !v.expiryRejected); if (spend.length) { const tip = ark._tipH; @@ -3963,16 +3981,12 @@ export function arkFeature(ctx) { }); } } - const manageBtn = () => h('button', { class: 'linklike small', style: 'align-self:flex-end', onClick: () => { ui.arkCoinsPage = true; render(); } }, t('arkDepthBtn')); - // Management follows the Spending balance, independently of notices - // or renewal eligibility. Keep expired coins reachable as well. - const balance = arkBalance(); - const coins = (arkStateNow()?.vtxos || []).some((v) => v.state === 'spendable'); - const hasBalance = balance && balance.spendableSat + balance.pendingSat > 0; - const manage = hasBalance || coins || lines.some((l) => l.manage) ? manageBtn() : null; - if (!lines.length) { - return manage ? [h('div', { class: 'small muted', style: 'margin:10px 0 0;text-align:center' }, manage)] : []; - } + // The coins themselves are always one tap away on the card's gear; a + // notice that is about them links straight there. + const manage = lines.some((l) => l.manage) + ? h('button', { class: 'linklike small', style: 'align-self:flex-end', onClick: () => ctx.openWalletSettings('spending') }, t('arkDepthBtn')) + : null; + if (!lines.length) return []; const err = lines.some((l) => l.err); // one plain advisory keeps its quiet centred line; anything more, or // anything urgent, is a card diff --git a/src/i18n.js b/src/i18n.js index 9b258b1..a7bc987 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -473,6 +473,7 @@ const STR = { arkDepthRenewed: 'Renewal requested', arkDepthRenewBtn: 'Renew now', arkCoinsTitle: 'Spending coins', + arkCoinsNone: 'No coins in Spending yet.', arkCoinsIntro: 'Your Spending balance lives in {n} coins totalling {total}. Each can be pulled on-chain without the server’s help, at a cost that grows with the coin’s history.', arkCoinsIntroOne: 'Your Spending balance lives in a single coin of {total}. It can be pulled on-chain without the server’s help, at a cost that grows with the coin’s history.', arkCoinsColAmount: 'Amount', @@ -816,6 +817,7 @@ const STR = { explorerUrlHint: 'Esplora / electrs REST base, e.g. https://mempool.space/api', walletName: 'Account name', walletSettings: 'Account settings', + securityTitle: 'Security', walletSettingsDesc: 'Name, recovery phrase, public key, and auto-logout for this account.', autolockTitle: 'Log out automatically', autolockDesc: "After the app has been in the background this long, log this wallet out. It's removed from this device (including from Save to device), so keep its recovery phrase. Other accounts aren't affected. The timer never runs while the app is open and focused.", diff --git a/src/locales/ar.json b/src/locales/ar.json index 09b789e..a3693d5 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -878,5 +878,7 @@ "followHistRestore": "استعادة {n}", "followHistNone": "لا توجد نسخة أقدم فيها أحد تفتقده قائمتك.", "followHistNothing": "كل من في تلك النسخة موجود في قائمتك بالفعل.", - "followHistRestored": "تمت استعادة {n} متابَعًا." + "followHistRestored": "تمت استعادة {n} متابَعًا.", + "securityTitle": "الأمان", + "arkCoinsNone": "لا توجد عملات في الإنفاق بعد." } diff --git a/src/locales/bn.json b/src/locales/bn.json index c5fc411..07c7fe2 100644 --- a/src/locales/bn.json +++ b/src/locales/bn.json @@ -878,5 +878,7 @@ "followHistRestore": "{n} জন ফিরিয়ে আনুন", "followHistNone": "কোনো পুরোনো কপিতে এমন কেউ নেই যিনি আপনার তালিকায় নেই।", "followHistNothing": "ওই কপির সবাই ইতিমধ্যে আপনার তালিকায় আছেন।", - "followHistRestored": "{n} জনকে আবার ফলো করা হয়েছে।" + "followHistRestored": "{n} জনকে আবার ফলো করা হয়েছে।", + "securityTitle": "নিরাপত্তা", + "arkCoinsNone": "স্পেন্ডিং-এ এখনও কোনো কয়েন নেই।" } diff --git a/src/locales/de.json b/src/locales/de.json index 626d548..75cbf74 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -878,5 +878,7 @@ "followHistRestore": "{n} wiederherstellen", "followHistNone": "Keine ältere Kopie enthält jemanden, der in deiner Liste fehlt.", "followHistNothing": "Alle aus dieser Kopie sind schon in deiner Liste.", - "followHistRestored": "{n} Follows wiederhergestellt." + "followHistRestored": "{n} Follows wiederhergestellt.", + "securityTitle": "Sicherheit", + "arkCoinsNone": "Noch keine Coins in Ausgaben." } diff --git a/src/locales/es.json b/src/locales/es.json index db30441..21adaa2 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -878,5 +878,7 @@ "followHistRestore": "Restaurar {n}", "followHistNone": "Ninguna copia anterior tiene a alguien que falte en tu lista.", "followHistNothing": "Todos los de esa copia ya están en tu lista.", - "followHistRestored": "Se restauraron {n} seguidos." + "followHistRestored": "Se restauraron {n} seguidos.", + "securityTitle": "Seguridad", + "arkCoinsNone": "Aún no hay monedas en Gastos." } diff --git a/src/locales/fr.json b/src/locales/fr.json index aeccf9c..899a586 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -878,5 +878,7 @@ "followHistRestore": "Restaurer {n}", "followHistNone": "Aucune copie plus ancienne ne contient quelqu’un qui manque à votre liste.", "followHistNothing": "Tout le monde dans cette copie est déjà dans votre liste.", - "followHistRestored": "{n} abonnements restaurés." + "followHistRestored": "{n} abonnements restaurés.", + "securityTitle": "Sécurité", + "arkCoinsNone": "Aucune pièce dans Dépenses pour l’instant." } diff --git a/src/locales/hi.json b/src/locales/hi.json index e683ef2..b26525c 100644 --- a/src/locales/hi.json +++ b/src/locales/hi.json @@ -878,5 +878,7 @@ "followHistRestore": "{n} बहाल करें", "followHistNone": "किसी पुरानी कॉपी में ऐसा कोई नहीं जो आपकी सूची में न हो।", "followHistNothing": "उस कॉपी के सभी लोग पहले से आपकी सूची में हैं।", - "followHistRestored": "{n} फ़ॉलो बहाल किए गए।" + "followHistRestored": "{n} फ़ॉलो बहाल किए गए।", + "securityTitle": "सुरक्षा", + "arkCoinsNone": "स्पेंडिंग में अभी कोई कॉइन नहीं।" } diff --git a/src/locales/id.json b/src/locales/id.json index beebc86..cff81be 100644 --- a/src/locales/id.json +++ b/src/locales/id.json @@ -214,5 +214,7 @@ "followHistRestore": "Pulihkan {n}", "followHistNone": "Tidak ada salinan lama yang berisi orang yang hilang dari daftarmu.", "followHistNothing": "Semua orang di salinan itu sudah ada di daftarmu.", - "followHistRestored": "{n} ikutan dipulihkan." + "followHistRestored": "{n} ikutan dipulihkan.", + "securityTitle": "Keamanan", + "arkCoinsNone": "Belum ada koin di Belanja." } diff --git a/src/locales/ja.json b/src/locales/ja.json index 9745063..58a188e 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -878,5 +878,7 @@ "followHistRestore": "{n}人を復元", "followHistNone": "あなたのリストに欠けている人を含む古いコピーはありません。", "followHistNothing": "そのコピーの全員がすでにリストにいます。", - "followHistRestored": "{n}人のフォローを復元しました。" + "followHistRestored": "{n}人のフォローを復元しました。", + "securityTitle": "セキュリティ", + "arkCoinsNone": "支出にはまだコインがありません。" } diff --git a/src/locales/ko.json b/src/locales/ko.json index 098bb7e..84da178 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -877,5 +877,7 @@ "followHistRestore": "{n}명 복원", "followHistNone": "목록에 빠진 사람이 들어 있는 예전 사본이 없습니다.", "followHistNothing": "그 사본의 모든 사람이 이미 목록에 있습니다.", - "followHistRestored": "팔로우 {n}명을 복원했습니다." + "followHistRestored": "팔로우 {n}명을 복원했습니다.", + "securityTitle": "보안", + "arkCoinsNone": "지출에 아직 코인이 없습니다." } diff --git a/src/locales/mr.json b/src/locales/mr.json index fa63503..66239cd 100644 --- a/src/locales/mr.json +++ b/src/locales/mr.json @@ -214,5 +214,7 @@ "followHistRestore": "{n} पुनर्संचयित करा", "followHistNone": "तुमच्या यादीत नसलेले कोणी असलेली जुनी प्रत नाही.", "followHistNothing": "त्या प्रतीतील सर्वजण आधीच तुमच्या यादीत आहेत.", - "followHistRestored": "{n} फॉलो पुनर्संचयित केले." + "followHistRestored": "{n} फॉलो पुनर्संचयित केले.", + "securityTitle": "सुरक्षा", + "arkCoinsNone": "स्पेंडिंगमध्ये अद्याप कोणतेही कॉइन नाहीत." } diff --git a/src/locales/pt.json b/src/locales/pt.json index e9e0ef8..3723f3f 100644 --- a/src/locales/pt.json +++ b/src/locales/pt.json @@ -878,5 +878,7 @@ "followHistRestore": "Restaurar {n}", "followHistNone": "Nenhuma cópia antiga tem alguém que falte na sua lista.", "followHistNothing": "Todos dessa cópia já estão na sua lista.", - "followHistRestored": "{n} seguidos restaurados." + "followHistRestored": "{n} seguidos restaurados.", + "securityTitle": "Segurança", + "arkCoinsNone": "Ainda não há moedas em Gastos." } diff --git a/src/locales/ru.json b/src/locales/ru.json index b89fbe0..66fb170 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -878,5 +878,7 @@ "followHistRestore": "Восстановить {n}", "followHistNone": "Нет старых копий с людьми, которых не хватает в вашем списке.", "followHistNothing": "Все из этой копии уже есть в вашем списке.", - "followHistRestored": "Восстановлено подписок: {n}." + "followHistRestored": "Восстановлено подписок: {n}.", + "securityTitle": "Безопасность", + "arkCoinsNone": "В расходах пока нет монет." } diff --git a/src/locales/sw.json b/src/locales/sw.json index 75f5a8b..b357408 100644 --- a/src/locales/sw.json +++ b/src/locales/sw.json @@ -214,5 +214,7 @@ "followHistRestore": "Rejesha {n}", "followHistNone": "Hakuna nakala ya zamani yenye mtu anayekosekana kwenye orodha yako.", "followHistNothing": "Wote kwenye nakala hiyo tayari wamo kwenye orodha yako.", - "followHistRestored": "Umerejesha unaowafuata {n}." + "followHistRestored": "Umerejesha unaowafuata {n}.", + "securityTitle": "Usalama", + "arkCoinsNone": "Bado hakuna sarafu kwenye Matumizi." } diff --git a/src/locales/ta.json b/src/locales/ta.json index 5007c0a..82dfb0a 100644 --- a/src/locales/ta.json +++ b/src/locales/ta.json @@ -214,5 +214,7 @@ "followHistRestore": "{n} மீட்டெடு", "followHistNone": "உங்கள் பட்டியலில் இல்லாதவர் உள்ள பழைய நகல் இல்லை.", "followHistNothing": "அந்த நகலில் உள்ள அனைவரும் ஏற்கனவே உங்கள் பட்டியலில் உள்ளனர்.", - "followHistRestored": "{n} பின்தொடர்வுகள் மீட்டெடுக்கப்பட்டன." + "followHistRestored": "{n} பின்தொடர்வுகள் மீட்டெடுக்கப்பட்டன.", + "securityTitle": "பாதுகாப்பு", + "arkCoinsNone": "செலவுக் கணக்கில் இன்னும் நாணயங்கள் இல்லை." } diff --git a/src/locales/te.json b/src/locales/te.json index a138c26..5c4751d 100644 --- a/src/locales/te.json +++ b/src/locales/te.json @@ -212,5 +212,7 @@ "followHistRestore": "{n} పునరుద్ధరించండి", "followHistNone": "మీ జాబితాలో లేని వ్యక్తి ఉన్న పాత కాపీ ఏదీ లేదు.", "followHistNothing": "ఆ కాపీలోని అందరూ ఇప్పటికే మీ జాబితాలో ఉన్నారు.", - "followHistRestored": "{n} ఫాలోలు పునరుద్ధరించబడ్డాయి." + "followHistRestored": "{n} ఫాలోలు పునరుద్ధరించబడ్డాయి.", + "securityTitle": "భద్రత", + "arkCoinsNone": "ఖర్చు ఖాతాలో ఇంకా నాణేలు లేవు." } diff --git a/src/locales/tr.json b/src/locales/tr.json index d8fcc50..6150425 100644 --- a/src/locales/tr.json +++ b/src/locales/tr.json @@ -877,5 +877,7 @@ "followHistRestore": "{n} kişiyi geri yükle", "followHistNone": "Hiçbir eski kopyada listende eksik olan biri yok.", "followHistNothing": "O kopyadaki herkes zaten listende.", - "followHistRestored": "{n} takip geri yüklendi." + "followHistRestored": "{n} takip geri yüklendi.", + "securityTitle": "Güvenlik", + "arkCoinsNone": "Harcama’da henüz coin yok." } diff --git a/src/locales/ur.json b/src/locales/ur.json index 5059007..458a0f4 100644 --- a/src/locales/ur.json +++ b/src/locales/ur.json @@ -214,5 +214,7 @@ "followHistRestore": "{n} بحال کریں", "followHistNone": "کسی پرانی کاپی میں ایسا کوئی نہیں جو آپ کی فہرست میں نہ ہو۔", "followHistNothing": "اس کاپی کے سب لوگ پہلے سے آپ کی فہرست میں ہیں۔", - "followHistRestored": "{n} فالو بحال ہو گئے۔" + "followHistRestored": "{n} فالو بحال ہو گئے۔", + "securityTitle": "سیکیورٹی", + "arkCoinsNone": "خرچ میں ابھی کوئی کوائن نہیں۔" } diff --git a/src/locales/vi.json b/src/locales/vi.json index 91ae1e4..06916ab 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -214,5 +214,7 @@ "followHistRestore": "Khôi phục {n}", "followHistNone": "Không bản cũ nào có người mà danh sách của bạn đang thiếu.", "followHistNothing": "Mọi người trong bản đó đã có trong danh sách của bạn.", - "followHistRestored": "Đã khôi phục {n} người theo dõi." + "followHistRestored": "Đã khôi phục {n} người theo dõi.", + "securityTitle": "Bảo mật", + "arkCoinsNone": "Chưa có coin nào trong Chi tiêu." } diff --git a/src/locales/zh.json b/src/locales/zh.json index 6b4c3a4..d6ea3f7 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -878,5 +878,7 @@ "followHistRestore": "恢复 {n} 人", "followHistNone": "没有旧副本包含你列表中缺少的人。", "followHistNothing": "该副本中的所有人都已在你的列表中。", - "followHistRestored": "已恢复 {n} 个关注。" + "followHistRestored": "已恢复 {n} 个关注。", + "securityTitle": "安全", + "arkCoinsNone": "支出账户中还没有币。" } diff --git a/src/style.css b/src/style.css index 0edb692..6a8af53 100644 --- a/src/style.css +++ b/src/style.css @@ -1264,6 +1264,8 @@ body::after { } .balance-switch:hover { color: var(--ink); border-color: var(--line-strong); } .balance-switch svg { opacity: .75; } +/* the face's settings: a round icon button, no label */ +.balance-gear { width: 34px; height: 34px; padding: 0; justify-content: center; } /* Move-money row: the action labels are long ("Move to Spending"), so let the row wrap to two full-width lines rather than crushing the amount field. */ .balance-move .input-group { flex-wrap: wrap; } diff --git a/tools/account-access-test.js b/tools/account-access-test.js index d2685de..4a83fd0 100644 --- a/tools/account-access-test.js +++ b/tools/account-access-test.js @@ -31,9 +31,9 @@ try { await page.reload(); await page.waitForSelector('.header-avatar'); check('fresh local account defaults to mainnet', await page.evaluate(() => JSON.parse(localStorage.getItem('btc-wallet-watch'))[0].network === 'mainnet')); - check('anonymous avatar has an accessible Accounts label', await page.$eval('.header-avatar', (e) => e.getAttribute('aria-label') === 'Accounts')); + check('anonymous avatar has an accessible label', await page.$eval('.header-avatar', (e) => e.getAttribute('aria-label') === 'Account settings')); await page.click('.header-avatar'); - check('avatar opens Accounts with sign-in and logout', await page.evaluate(() => ['Accounts', 'Sign into another account', 'Log out'].every((s) => document.body.innerText.includes(s)))); + check('avatar opens the wallet settings with sign-in and logout', await page.evaluate(() => ['Security', 'Sign into another account', 'Log out'].every((s) => document.body.innerText.includes(s)))); const saved = await page.evaluate(() => localStorage.getItem('btc-wallet-watch')); await click('Sign into another account'); check('sign-in opens on Create new, not the seed import box', await page.evaluate(() => @@ -57,28 +57,29 @@ try { }, { key: cacheKeyFor(xpub) }); await page.reload(); await page.waitForSelector('[aria-label="Messages"]'); - await click('Accounts'); - const onAccounts = () => page.evaluate(() => [...document.querySelectorAll('h3')].some(e => e.textContent === 'Accounts')); + // the balance card's gear: a page like any other under the header + await page.click('.balance-gear'); + const onSettings = () => page.evaluate(() => history.state.nav.screen === 'accountSettings' && [...document.querySelectorAll('h3')].some(e => e.textContent === 'Security')); for (const label of ['Search', 'Messages', 'Your profile', 'Settings']) { - check(label + ' starts on Accounts', await onAccounts()); + check(label + ' starts on the wallet settings', await onSettings()); await page.click('[aria-label="' + label + '"]'); - check(label + ' opens from Accounts', await page.evaluate(label => { + check(label + ' opens from the wallet settings', await page.evaluate(label => { const nav = history.state.nav; - return nav.screen === 'wallet' && ![...document.querySelectorAll('h3')].some(e => e.textContent === 'Accounts') + return nav.screen === 'wallet' && (label === 'Search' ? !!document.querySelector('.user-search-input') : label === 'Messages' ? nav.chatOpen && nav.msgView === 'home' - : label === 'Your profile' ? !!document.querySelector('.npub-box') && nav.profilePk === 'a'.repeat(64) + : label === 'Your profile' ? nav.profilePk === 'a'.repeat(64) : nav.tab === 'settings'); }, label)); await page.goBack(); - await page.waitForFunction(() => [...document.querySelectorAll('h3')].some(e => e.textContent === 'Accounts')); - check('Back returns from ' + label + ' to Accounts', await onAccounts()); + await page.waitForFunction(() => history.state.nav.screen === 'accountSettings'); + check('Back returns from ' + label + ' to the wallet settings', await onSettings()); } await page.click('[aria-label="Lock wallet"]'); - check('Lock opens its password prompt from Accounts', await page.evaluate(() => !!document.querySelector('input[type="password"]'))); + check('Lock opens its password prompt from the wallet settings', await page.evaluate(() => !!document.querySelector('input[type="password"]'))); await click('Not now'); await page.click('.brand'); - check('the logo returns to the wallet', await page.evaluate(() => history.state.nav.screen === 'wallet' && ![...document.querySelectorAll('h3')].some(e => e.textContent === 'Accounts'))); + check('the logo returns to the wallet', await page.evaluate(() => history.state.nav.screen === 'wallet' && !!document.querySelector('.balance-gear'))); assert.deepEqual(errors, []); check('no browser errors', true); } finally { await browser.close(); server.stop(true); } diff --git a/tools/ark/cooperative-exit-ui-test.js b/tools/ark/cooperative-exit-ui-test.js index 1af394e..831ed3b 100644 --- a/tools/ark/cooperative-exit-ui-test.js +++ b/tools/ark/cooperative-exit-ui-test.js @@ -8,10 +8,11 @@ import { wordlist } from '@scure/bip39/wordlists/english'; const bundle = await Bun.build({ entrypoints: ['src/app.js'], target: 'browser', minify: true, plugins: [{ name: 'cooperative-exit-test', setup(build) { build.onLoad({ filter: /src\/app\.js$/ }, async ({ path }) => ({ loader: 'js', contents: await Bun.file(path).text() + ` -window.coopApp = { ui, wallet, ctx, render, enterWallet, get ready() { return !_bootDeciding; } }; +window.coopApp = { ui, wallet, ctx, render, enterWallet, get ready() { return !_bootDeciding; }, + setSpending: () => { const a = activeAccount(); if (a) a.kind = 'spending'; } }; ` })); build.onLoad({ filter: /src\/features\/ark\.js$/ }, async ({ path }) => ({ loader: 'js', contents: - (await Bun.file(path).text()).replace(" return {\n id: 'ark',", ` + (await Bun.file(path).text()).replace(' arkReady() { return arkAvailable(); },', ' arkReady() { return window.forceArkReady || arkAvailable(); },').replace(" return {\n id: 'ark',", ` window.coopFeature = { setManager: (mgr) => { ark = mgr; }, offboard: doArkOffboard }; return {\n id: 'ark',`) })); build.onLoad({ filter: /src\/video-compress\.js$/ }, () => ({ loader: 'js', contents: 'export async function compressVideo(f) { return f; }' })); @@ -70,6 +71,28 @@ try { const send = () => page.evaluate(() => [...document.querySelectorAll('.card')] .find((c) => c.querySelector('h3')?.textContent === 'Co-operative exit').querySelector('.btn-primary').click()); const edit = (selector, value) => page.$eval(selector, (e, value) => { e.value = value; e.dispatchEvent(new Event('input', { bubbles: true })); }, value); + // The balance card's gear is the way in: Spending's settings lead with + // its coins and carry what a unilateral exit costs; the home screen no + // longer says it under the balance. + await page.evaluate(() => { window.forceArkReady = true; coopApp.wallet.saveFeatureState('arkDepth', { show: true, exitFee: 4321, key: 'x' }); coopApp.setSpending(); coopApp.render(); }); + const homeText = await page.evaluate(() => document.body.innerText); + assert(!/Unilateral exit costs/.test(homeText), 'the exit cost is not on the home screen'); + assert(!/Accounts/.test(homeText), 'no Accounts button on the home screen'); + await page.screenshot({ path: '/tmp/gear-home.png' }); + const gears = await page.$$eval('.balance-gear', (es) => es.length); + assert(gears >= 1, 'the balance card wears a gear'); + const faces = await page.$$eval('.balance-face', (fs) => fs.map((f) => f.innerText.split('\n')[0])); + assert(faces.some((f) => /spending/i.test(f)), 'a Spending face: ' + JSON.stringify(faces)); + await page.evaluate(() => [...document.querySelectorAll('.balance-face')].find((f) => /spending/i.test(f.innerText.split('\n')[0])).querySelector('.balance-gear').click()); + await page.waitForSelector('.coin-table'); + const st = await page.evaluate(() => ({ nav: coopApp.ui.screen, text: document.body.innerText })); + assert.equal(st.nav, 'accountSettings'); + assert.match(st.text, /Unilateral exit costs about 4,?321 sats/, 'the exit cost lives on the settings page'); + assert.match(st.text, /Security/); assert.match(st.text, /Lock wallet/); assert.match(st.text, /Recovery phrase/); + await page.screenshot({ path: '/tmp/gear-settings.png', fullPage: true }); + console.log('✓ The Spending gear opens its settings: coins, the exit cost (gone from home), lock and recovery phrase'); + await page.evaluate(() => { window.forceArkReady = false; coopApp.wallet.saveFeatureState('arkDepth', null); coopApp.ui.screen = 'wallet'; coopApp.render(); }); + await manage(); assert.deepEqual(await page.$$eval('.card h4', (es) => es.map((e) => e.textContent)), ['Renewal', 'Co-operative exit', 'Unilateral exit']); await coopButton(); diff --git a/tools/lock-wallet-test.js b/tools/lock-wallet-test.js index 13d2b91..107ccd0 100644 --- a/tools/lock-wallet-test.js +++ b/tools/lock-wallet-test.js @@ -82,8 +82,8 @@ try { await sleep(1000); console.log('\n[the padlock still asks — locking needs a password]'); - // the padlock lives on the Accounts page (the balance card's switcher opens it) - await page.evaluate(() => document.querySelector('.balance-switch')?.click()); + // the padlock lives on the wallet settings page (the balance card's gear) + await page.evaluate(() => document.querySelector('.balance-gear')?.click()); await sleep(600); await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((n) => n.title === 'Lock wallet'); if (b) b.click(); }); await sleep(600); @@ -91,7 +91,7 @@ try { check('padlock asks even after "Not now"', /protect this device/i.test(txt), txt.slice(0, 120).replace(/\n+/g, ' | ')); await click('button', 'Not now'); await sleep(600); - check('and declining lands back on the Accounts page', /add account/i.test(await body())); + check('and declining lands back on the wallet settings', /security/i.test(await body())); console.log('\n[now with a password]'); await click('button', 'Change password'); @@ -139,13 +139,17 @@ try { check('backing out deletes nothing', (await vaultSize()) > 0); console.log('\n[delete all is the destructive one]'); - await page.evaluate(() => document.querySelector('.balance-switch')?.click()); + // the same road as above, this time all the way: profile → Log out → + // forget all data → the warning → Delete all + await page.evaluate(() => document.querySelector('.header-avatar')?.click()); await sleep(800); - await click('button', 'Delete all'); + await click('button', 'Log out'); + await sleep(500); + await click('button', 'forget all data'); await sleep(600); txt = await body(); check('it warns first', /delete all/i.test(txt) && /(delete|remove|erase|wipe|lose)/i.test(txt), txt.slice(0, 160).replace(/\n+/g, ' | ')); - await page.evaluate(() => { const b = [...document.querySelectorAll('.btn-primary')].find((e) => /delete all/i.test(e.textContent)); if (b) b.click(); }); + await page.evaluate(() => { const b = [...document.querySelectorAll('.confirm-pop button')].find((e) => /delete all/i.test(e.textContent)); if (b) b.click(); }); await sleep(1200); txt = await body(); check('everything is gone', (await vaultSize()) === 0); diff --git a/tools/padlock-ask-test.js b/tools/padlock-ask-test.js index f1fa3cf..5570b8f 100644 --- a/tools/padlock-ask-test.js +++ b/tools/padlock-ask-test.js @@ -35,25 +35,25 @@ try { await click('button', 'Open wallet'); await waitText('receive', 20000); - // open the Accounts page (the balance card's switcher) — the padlock lives there - await page.evaluate(() => document.querySelector('.balance-switch')?.click()); + // open the balance card's settings (its gear) — the padlock lives there + await page.evaluate(() => document.querySelector('.balance-gear')?.click()); await sleep(900); - check('Accounts page is open', /add account/i.test(await bodyText()), (await bodyText()).slice(0, 60).replace(/\n/g, ' | ')); + check('the wallet settings page is open', /security/i.test(await bodyText()), (await bodyText()).slice(0, 60).replace(/\n/g, ' | ')); // tap the padlock — the ask must appear without navigating anywhere const tapped = await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /lock/i.test(e.getAttribute('aria-label') || '')); if (!b) return false; b.click(); return true; }); - check('padlock is reachable from the Accounts page', tapped); + check('padlock is reachable from the wallet settings', tapped); await sleep(500); const text = await bodyText(); check('the ask appears on the tap', /protect this device/i.test(text), text.split('\n').slice(0, 3).join(' | ')); - // declining lands back on the Accounts page, not somewhere else + // declining lands back on the settings page, not somewhere else await click('button', 'not now'); await sleep(600); - check('declining lands back on the Accounts page', /add account/i.test(await bodyText()), (await bodyText()).slice(0, 60).replace(/\n/g, ' | ')); + check('declining lands back on the wallet settings', /security/i.test(await bodyText()), (await bodyText()).slice(0, 60).replace(/\n/g, ' | ')); } finally { await browser.close(); server.stop(true); } |
