diff options
| author | Adam Soltys <[email protected]> | 2026-09-02 19:32:50 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-02 19:32:50 -0700 |
| commit | a7f068c896be7f59520fdfe186a23037353bca13 (patch) | |
| tree | 47b39d42201a5a6a6b1e69b102ae7e0b44c1be43 /src | |
| parent | 26347064323d5b9166e9bc52b0118bf729b6fd44 (diff) | |
| download | coinosv3-a7f068c896be7f59520fdfe186a23037353bca13.tar.gz coinosv3-a7f068c896be7f59520fdfe186a23037353bca13.zip | |
The balance grows a card carousel: Spending and Savings become side-by-side faces with the neighbor peeking at the card's edge, dragged and snapped by native scroll-snap — settling on a face IS the account switch, programmatic switches glide the carousel to match, and the old swipe-to-flip gesture yields to the carousel's own drag (it still serves single-face wallets)
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MrkLsNScXe9b6BviUSdmMr
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 119 | ||||
| -rw-r--r-- | src/style.css | 10 |
2 files changed, 92 insertions, 37 deletions
@@ -3281,48 +3281,90 @@ function balanceCard() { ? accounts.find((x) => x.id !== acc0.id && x.mnemonic && x.mnemonic === acc0.mnemonic && (x.passphrase || '') === (acc0.passphrase || '') && viewsOf(x).includes(opp)) : null; - // One balance at a time, full size: the card IS the account you're in, and - // everything (receive, gifts) follows it. Swipe or tap the dots to flip. - const dtRaw = animWindow('acct', sel, 300); - // Decide AT the change whether this face switch animates: only deliberate - // switches (tap/swipe set _accDir) slide — a background flip (arkReady - // arriving and revealing Spending right after sign-in) must not. - if (dtRaw >= 0 && dtRaw < 16.7) _accAnim = !!_accDir && !ui.navAnimSkip; - const dtAcc = _accAnim ? dtRaw : -1; - const face = h('div', { class: 'balance-face' }, - h('div', { class: 'row between', style: 'align-items:center' }, - h('div', { class: 'small faint', style: 'text-transform:uppercase;letter-spacing:.06em' }, - kindLocked ? viewLabel(acc0, sel) - : hasSpending ? (isSpending ? t('spendingLabel') : t('savingLabel')) : t('balance')), - // Swiping only helps on touch, and dots are an indicator rather than a - // target — so the switch is a real button that names where it goes. - // Before Spending exists, its spot offers to set it up — the one - // doorway into the optional spending-account flow. - h('button', { - class: 'balance-switch', - onClick: () => { ui.screen = 'accounts'; render(); }, + // The balance area: with both views live it's a CAROUSEL — the other face + // 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'))); + const faceFor = (view) => { + const viewSpending = view === 'spending'; + return h('div', { class: 'balance-face' }, + h('div', { class: 'row between', style: 'align-items:center' }, + 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()), + 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')), + (pending > 0 && !viewSpending) || (viewSpending && featLines.length) + ? h('div', { class: 'split' }, + // Pending incoming is an on-chain fact — it belongs to the Saving + // face; the feature lines (ark's Moving…) belong to Spending. + pending > 0 && !viewSpending + ? h('div', {}, h('div', { class: 'k' }, t('pending')), h('div', { class: 'v pending' }, fmtAmount(pending), ' ', unitTag())) + : null, + ...(viewSpending ? featLines : []).map((l) => + h('div', {}, h('div', { class: 'k' }, l.label), h('div', { class: 'v' }, fmtAmount(l.sat), ' ', unitTag())))) + : null); + }; + const carouselMode = hasSpending && !kindLocked; + let heroEl; + if (carouselMode) { + const ORDER2 = ['spending', 'savings']; + heroEl = h('div', { + class: 'bal-carousel', + onScroll: (e) => { + const el = e.target; + el._lastScroll = Date.now(); + clearTimeout(el._settle); + el._settle = setTimeout(() => { + const mid = el.scrollLeft + el.clientWidth / 2; + let best = 0, bestD = Infinity; + [...el.children].forEach((k, i) => { + const d = Math.abs(k.offsetLeft + k.offsetWidth / 2 - mid); + if (d < bestD) { bestD = d; best = i; } + }); + const view = ORDER2[best]; + if (view && view !== accountSel()) { + ui.account = view; + try { localStorage.setItem(ACCOUNT_KEY, view); } catch {} + render(); + } + }, 120); }, - 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')))), - h('div', { class: 'amt', style: firstLoad ? 'opacity:.3' : '' }, - firstLoad ? h('span', { class: 'spinner sm', style: 'margin-right:8px' }) : null, - animatedAmount('bal:' + sel, isSpending ? spending : saving), ' ', unitTag('unit')), - (pending > 0 && !isSpending) || featLines.length - ? h('div', { class: 'split' }, - // Pending incoming is an on-chain fact — it belongs to the Saving - // face, not to whichever account happens to be showing. - pending > 0 && !isSpending - ? h('div', {}, h('div', { class: 'k' }, t('pending')), h('div', { class: 'v pending' }, fmtAmount(pending), ' ', unitTag())) - : null, - ...featLines.map((l) => - h('div', {}, h('div', { class: 'k' }, l.label), h('div', { class: 'v' }, fmtAmount(l.sat), ' ', unitTag())))) - : null); - applyAnim(face, 'anim-tab-' + (_accDir || 'left'), dtAcc); + }, ORDER2.map((v) => h('div', { class: 'bal-slide' }, faceFor(v)))); + // Align the carousel to the selected face after this render lands — but + // never fight a finger or live momentum. + setTimeout(() => { + const el = document.querySelector('.bal-carousel'); + if (!el || Date.now() - (el._lastScroll || 0) < 350) return; + const k = el.children[ORDER2.indexOf(sel)]; + if (!k) return; + const target = k.offsetLeft + k.offsetWidth / 2 - el.clientWidth / 2; + if (Math.abs(el.scrollLeft - target) > 6) el.scrollTo({ left: target, behavior: el._inited ? 'smooth' : 'auto' }); + el._inited = true; + }, 0); + } else { + const dtRaw = animWindow('acct', sel, 300); + // Decide AT the change whether this face switch animates: only deliberate + // switches (tap/swipe set _accDir) slide — a background flip (arkReady + // arriving and revealing Spending right after sign-in) must not. + if (dtRaw >= 0 && dtRaw < 16.7) _accAnim = !!_accDir && !ui.navAnimSkip; + const dtAcc = _accAnim ? dtRaw : -1; + heroEl = faceFor(sel); + applyAnim(heroEl, 'anim-tab-' + (_accDir || 'left'), dtAcc); + } _accDir = null; return h( 'div', { class: 'card balance' }, - face, + heroEl, // "Move money" and friends: the only door between the two balances — // pointless (and confusing) while there's only one. ...(hasSpending ? featureAll('balanceActions') : []).map((a2) => @@ -4566,6 +4608,9 @@ applyDir(); live = e.touches.length === 1 && ui.screen === 'wallet' && !ui.chatOpen && !ui.profilePk && !ui.arkExitPage && ORDER.includes(ui.tab); if (!live) return; + // the carousel owns its own drag — the flip gesture only serves the + // single-face balance (kind-locked / savings-only wallets) + if (e.target.closest && e.target.closest('.bal-carousel')) { live = false; return; } onBalance = !!(e.target.closest && e.target.closest('.balance')); sx = e.touches[0].clientX; sy = e.touches[0].clientY; diff --git a/src/style.css b/src/style.css index 1ed2195..933fa69 100644 --- a/src/style.css +++ b/src/style.css @@ -441,6 +441,16 @@ hr.divider { margin: 16px 0; } /* Inline move-money panel on the balance card. */ .balance-move { gap: 10px; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--line); text-align: left; } +/* balance carousel: the other face peeks at the card's edge and drags into + place — native scroll-snap does the physics. Full-bleed inside the card + so the peeking neighbor touches the card wall. */ +.bal-carousel { + display: flex; gap: 14px; overflow-x: auto; scroll-snap-type: x mandatory; + margin: -20px; padding: 20px; scrollbar-width: none; -ms-overflow-style: none; + touch-action: pan-x pan-y; +} +.bal-carousel::-webkit-scrollbar { display: none; } +.bal-slide { flex: 0 0 calc(100% - 34px); scroll-snap-align: center; min-width: 0; } .balance-move .seg { display: flex; width: 100%; } .balance-move .seg button { flex: 1; } .send-suggest { max-height: 260px; overflow-y: auto; } |
