From bae797fb402e0d9b86c43c5c1dd356cb056e2838 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 2 Aug 2026 23:04:16 -0700 Subject: Motion and polish: navigation choreography, counting balance, gold ribbon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The renderer rebuilds the DOM on every render, so naive CSS mount animations would replay on every background repaint. Two primitives fix that: uiChanged() gates one-shot animations to the render where the thing actually happened, and animWindow() lets a rebuild that lands mid-animation RESUME it via a negative animation-delay instead of killing or restarting it. On top of those: every real navigation rises in (screen/tab/subpage/ chat/profile — one keyed check in render()); the balance counts to its new value and breathes green when money arrives; the avatar menu pops from its corner; the move panel unfolds; the success check springs and rings once when its page lands. Micro-transitions on buttons, tabs, segs, inputs, list rows, avatars, and the settings chevrons. One chromatic signature on the monochrome brand: a slow-drifting bitcoin-gold ribbon across the top of the balance card, echoed by a faint warm wash pooling at the top of the page, plus a film-grain overlay for texture. All of it honors prefers-reduced-motion. The nostr sign-in mark also gets a proper inset ostrich and a size bump. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi --- src/app.js | 75 +++++++++++++++++++++++++++++++++++++++++----- src/features/nostrlogin.js | 2 +- src/style.css | 62 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 8 deletions(-) (limited to 'src') diff --git a/src/app.js b/src/app.js index fbaa8e1..ce2ed3d 100644 --- a/src/app.js +++ b/src/app.js @@ -269,6 +269,55 @@ function nodeAtPath(path) { return n || null; } +// ---- one-shot animation gating ------------------------------------------- +// The renderer rebuilds the whole DOM on every render, so a CSS mount +// animation would replay on every background repaint (balance ticks, chat +// bursts). These helpers answer "did this change since the last render?" — +// things animate only in the render where they actually happened. +const _uiSeen = new Map(); +function uiChanged(key, val) { + const prev = _uiSeen.get(key); + _uiSeen.set(key, val); + return prev !== val; +} +// A background render can land mid-animation and rebuild the node without +// its one-shot class, killing the motion. animWindow answers "how far into +// the animation started by this change are we?" — callers re-apply the class +// with a negative animation-delay so a rebuild RESUMES the animation. +const _animAt = new Map(); +function animWindow(key, val, durMs) { + if (uiChanged(key, val)) _animAt.set(key, performance.now()); + const dt = performance.now() - (_animAt.get(key) ?? -1e9); + return dt < durMs ? dt : -1; +} +function applyAnim(el, cls, dt) { + if (dt < 0 || !el || !el.classList) return; + el.classList.add(cls); + el.style.animationDelay = -Math.round(dt) + 'ms'; +} + +// A balance that counts to its new value instead of teleporting, with a green +// breath when money arrived. Keyed so background renders don't re-animate. +const _amtLast = new Map(); +function animatedAmount(key, sat) { + const prev = _amtLast.get(key); + _amtLast.set(key, sat); + const el = h('span', {}, fmtAmount(sat)); + if (prev !== undefined && prev !== sat) { + const from = prev, to = sat, t0 = performance.now(), dur = 550; + const step = (t) => { + if (!el.isConnected) return; // a newer render owns the number now + const p = Math.min(1, (t - t0) / dur); + const e = 1 - Math.pow(1 - p, 3); + el.textContent = fmtAmount(Math.round(from + (to - from) * e)); + if (p < 1) requestAnimationFrame(step); + }; + requestAnimationFrame(step); + if (to > from) el.classList.add('amt-flash'); + } + return el; +} + function render() { // A direct render subsumes any coalesced one queued by a background emit, so // drop the pending frame (a user action must not trigger a second rebuild). @@ -294,6 +343,11 @@ function render() { : ui.screen === 'howItWorks' ? howItWorksScreen() : unlockScreen()); + // Navigation animates; background repaints must not. The key is every + // ui field that decides which page is on screen. + const navKey = [ui.screen, ui.tab, ui.chatOpen, ui.msgView, ui.msgPeer, ui.msgCommunity, + ui.profilePk, ui.settingsPage, ui.addrScan, ui.arkExitPage, ui.txDetail, ui.giftMode, ui.claimStep].join('|'); + applyAnim(screen, 'anim-page', animWindow('nav', navKey, 340)); root.replaceChildren(screen, footer()); if (fpath) { const el = nodeAtPath(fpath); @@ -1357,15 +1411,15 @@ function avatarMenu() { }, label); return h('span', { class: 'avatar-menu-wrap' }, h('button', { class: 'header-avatar', onClick: () => { ui.avatarMenu = !ui.avatarMenu; render(); } }, node), - ui.avatarMenu + (() => { const dt = animWindow('avmenu', !!ui.avatarMenu, 220); return ui.avatarMenu ? h('span', {}, h('div', { class: 'menu-backdrop', onClick: () => { ui.avatarMenu = false; render(); } }), - h('div', { class: 'icon-select-menu avatar-menu' }, + (() => { const m = h('div', { class: 'icon-select-menu avatar-menu' }, me ? item(t('profEdit'), () => featureHook('showProfile', me)) : null, item(t('tabSettings'), () => { ui.chatOpen = false; ui.profilePk = null; ui.screen = 'wallet'; ui.tab = 'settings'; ui.settingsPage = null; }), me ? item(t('msgDmsTitle'), () => { ui.profilePk = null; ui.chatOpen = true; }) : null, - item(t('logout'), () => lock()))) - : null); + item(t('logout'), () => lock())); applyAnim(m, 'anim-menu', dt); return m; })()) + : null; })()); } function brandHeader(withLock) { @@ -2024,7 +2078,7 @@ function balanceCard() { : h('div', { class: 'small faint', style: 'text-transform:uppercase;letter-spacing:.05em' }, t('balance')), h('div', { class: 'amt', style: firstLoad ? 'opacity:.3' : '' }, firstLoad ? h('span', { class: 'spinner sm', style: 'margin-right:8px' }) : null, - fmtAmount(sel === 'spending' ? spending : saving), ' ', unitTag('unit')), + animatedAmount('bal:' + sel, sel === 'spending' ? spending : saving), ' ', unitTag('unit')), hasArk || pending > 0 || featLines.length ? h( 'div', @@ -2032,7 +2086,7 @@ function balanceCard() { hasArk ? h('div', {}, h('div', { class: 'k' }, sel === 'spending' ? t('savingLabel') : t('spendingLabel')), - h('div', { class: 'v' }, fmtAmount(sel === 'spending' ? saving : spending), ' ', unitTag())) + h('div', { class: 'v' }, animatedAmount('bal2:' + sel, sel === 'spending' ? saving : spending), ' ', unitTag())) : null, pending > 0 ? h('div', {}, h('div', { class: 'k' }, t('pending')), h('div', { class: 'v pending' }, fmtAmount(pending), ' ', unitTag())) @@ -2045,7 +2099,14 @@ function balanceCard() { ...featureAll('balanceActions').map((a2) => h('button', { class: 'btn-sm', style: 'margin-top:10px', onClick: a2.onClick }, a2.label)), // A feature can unfold UI right on the card (ark's inline move panel). - featureHook('balanceExtra') + (() => { + const dt = animWindow('unfold', !!ui.arkMoveOpen, 300); + const extra = featureHook('balanceExtra'); + if (!extra) return null; + const w = h('div', {}, extra); + applyAnim(w, 'anim-unfold', dt); + return w; + })() ); } diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js index f2605ed..c7254d3 100644 --- a/src/features/nostrlogin.js +++ b/src/features/nostrlogin.js @@ -17,7 +17,7 @@ import { } from '../nostr-login.js'; // The nostr ostrich, in the brand's monochrome. -const OSTRICH = ' '; +const OSTRICH = ''; export function nostrLoginFeature(ctx) { diff --git a/src/style.css b/src/style.css index ca85a8c..f01df60 100644 --- a/src/style.css +++ b/src/style.css @@ -27,6 +27,7 @@ --info-soft: #eef2ff; --info-line: #d4ddfb; --radius: 16px; + --wash: rgba(247, 147, 26, 0.05); /* warm glow behind the page top */ --mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace; --sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; } @@ -55,6 +56,7 @@ --toast-bg: #2c323b; /* raised toast that reads against the dark page */ --toast-fg: #f3f5f8; --tab-bg: #20242b; + --wash: rgba(247, 147, 26, 0.07); --info: #9db4ff; --info-soft: #1a2030; --info-line: #2c3a5e; @@ -406,3 +408,63 @@ hr.divider { margin: 16px 0; } .chat-avatar.fallback.loading { background: var(--surface2); } .wallet-row-btn { font-size: 19px; line-height: 1; padding: 8px 12px; } .chat-avatar.header-ava.fallback { align-items: center; justify-content: center; display: flex; color: var(--muted); } + +/* ============================ motion & polish ============================ */ +/* Navigation animates (the renderer adds .anim-page only when the visible + page changed); background repaints re-create the DOM silently. */ +@keyframes rise-in { from { opacity: 0; transform: translateY(10px); } } +.anim-page { animation: rise-in .32s cubic-bezier(.16, .84, .3, 1) both; } + +@keyframes menu-pop { from { opacity: 0; transform: translateY(-6px) scale(.95); } } +.anim-menu { animation: menu-pop .2s cubic-bezier(.2, .9, .3, 1.35) both; transform-origin: top right; } + +@keyframes unfold-in { from { opacity: 0; transform: translateY(-10px); } } +.anim-unfold { animation: unfold-in .28s cubic-bezier(.16, .84, .3, 1) both; } + +@keyframes flash-green { 0% { color: var(--green); } 100% { color: inherit; } } +.amt-flash { animation: flash-green 1.2s ease-out; } + +/* the success check pops and rings, once, when its page arrives */ +@keyframes check-pop { 0% { transform: scale(.3); opacity: 0; } 60% { transform: scale(1.12); } 100% { transform: scale(1); opacity: 1; } } +@keyframes check-ring { from { box-shadow: 0 0 0 0 color-mix(in srgb, var(--green) 45%, transparent); } to { box-shadow: 0 0 0 30px transparent; } } +.anim-page .check-badge { animation: check-pop .55s cubic-bezier(.34, 1.56, .64, 1) both, check-ring 1.1s ease-out .25s both; } + +/* micro-interactions */ +button, .btn { transition: filter .12s, background .15s, border-color .15s, box-shadow .15s, transform .1s; } +.btn-primary:hover { filter: brightness(1.15); } +.tabs button, .seg button { transition: background .18s, color .18s, box-shadow .18s; } +.item { transition: background .15s; border-radius: 10px; } +.item:hover { background: var(--surface2); } +.clickable { transition: transform .15s ease; } +.chat-avatar.clickable:hover { transform: scale(1.07); } +.header-avatar .chat-avatar { transition: transform .15s ease; } +.header-avatar:hover .chat-avatar { transform: scale(1.08); } +input[type=text], input[type=password], input[type=number], textarea, select { transition: border-color .15s, box-shadow .15s; } +.settings-nav::after { transition: transform .18s ease, color .18s; } +.settings-nav:hover::after { transform: translateX(3px); color: var(--ink); } + +/* the one chromatic signature: a slow bitcoin-gold ribbon on the balance card */ +.balance { position: relative; overflow: hidden; } +.balance::before { + content: ''; position: absolute; inset: 0 0 auto 0; height: 3px; + background: linear-gradient(90deg, #f7931a, #ffd166 30%, #f2a900 55%, #ffb84d 75%, #f7931a); + background-size: 200% 100%; + animation: ribbon 8s linear infinite; +} +@keyframes ribbon { to { background-position: -200% 0; } } + +/* atmosphere: a faint warm wash pooling at the top, plus film grain */ +body { + background-image: radial-gradient(900px 420px at 50% -160px, var(--wash), transparent 70%); + background-attachment: fixed; +} +body::after { + content: ''; position: fixed; inset: 0; pointer-events: none; z-index: 2147483647; + opacity: .028; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); +} + +@media (prefers-reduced-motion: reduce) { + .anim-page, .anim-menu, .anim-unfold, .amt-flash, .check-badge, .balance::before { animation: none !important; } + *, *::before, *::after { transition-duration: .01ms !important; } +} -- cgit v1.2.3