diff options
| author | Adam Soltys <[email protected]> | 2026-08-02 23:04:16 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-02 23:04:16 -0700 |
| commit | bae797fb402e0d9b86c43c5c1dd356cb056e2838 (patch) | |
| tree | c4ad46b037c60132636ee278e6201fd779ed493e /src | |
| parent | 24fbf5e2b75efff43a22b129dbe30510b1b34dd0 (diff) | |
| download | coinosv3-bae797fb402e0d9b86c43c5c1dd356cb056e2838.tar.gz coinosv3-bae797fb402e0d9b86c43c5c1dd356cb056e2838.zip | |
Motion and polish: navigation choreography, counting balance, gold ribbon
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 75 | ||||
| -rw-r--r-- | src/features/nostrlogin.js | 2 | ||||
| -rw-r--r-- | src/style.css | 62 |
3 files changed, 131 insertions, 8 deletions
@@ -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 = '<svg width="20" height="20" style="display:block" viewBox="0 0 2000 2000" fill="none" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="ng" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#B84FE8"/><stop offset="100%" stop-color="#6D28D9"/></linearGradient></defs> <path d="M1000 0C447.755 0 0 447.755 0 1000C0 1552.24 447.755 2000 1000 2000C1552.24 2000 2000 1552.24 2000 1000C2000 447.755 1552.24 0 1000 0ZM1574.29 770.408C1532.04 840.612 1445.92 888.367 1437.96 892.449C1421.84 901.02 1415.92 913.469 1416.94 932.041C1417.96 950.612 1413.47 1042.65 1357.35 1083.27C1328.57 1104.08 1212.45 1131.02 1159.18 1140C1131.84 1144.69 1125.51 1153.06 1110.41 1162.04C1090 1174.69 1017.55 1275.1 1005.31 1297.35C1006.94 1297.14 1233.67 1226.73 1243.88 1225.1C1270.82 1221.02 1291.22 1228.78 1304.49 1248.37C1317.14 1267.14 1329.59 1286.12 1342.04 1305.1C1346.53 1311.84 1350.82 1318.78 1355.1 1325.71L1359.59 1332.86C1361.22 1335.51 1363.47 1338.57 1364.49 1342.65C1365.31 1345.51 1367.14 1355.1 1361.63 1360.82C1356.53 1366.12 1347.76 1366.12 1343.06 1364.9C1338.57 1363.67 1331.84 1361.02 1326.33 1356.53L1318.78 1350.2C1312.86 1345.1 1301.63 1342.65 1292.86 1343.06C1282.65 1343.47 1269.18 1340.2 1262.65 1336.12C1250 1327.96 1246.73 1307.55 1246.73 1307.35C1245.1 1300.61 1240.2 1298.78 1235.71 1298.78C1227.55 1298.57 1221.22 1301.02 1214.69 1302.86C1184.9 1311.84 1155.31 1321.02 1125.51 1330.41L1082.86 1343.67C1061.84 1350.2 1040.82 1356.73 1020 1363.67C1014.29 1365.51 1008.78 1368.37 1002.86 1371.43C1000.41 1372.65 997.959 1373.88 995.51 1375.1C993.061 1376.33 990.408 1377.76 987.959 1379.18C982.449 1382.24 976.531 1385.51 970 1387.35C948.776 1393.67 930.204 1384.49 921.429 1363.27C915.714 1349.59 920.816 1322.45 923.878 1314.49C943.061 1264.69 1005.51 1170.82 1005.51 1170.2C1004.49 1169.8 936.327 1197.14 925.102 1205.71C894.286 1229.18 822.041 1284.69 821.02 1290C816.327 1314.69 801.02 1333.27 777.551 1342.45C760.612 1348.98 750.408 1362.45 740.408 1375.51C740.408 1375.51 559.388 1624.9 547.143 1638.37C543.469 1642.45 509.592 1677.96 500.612 1694.49C498.571 1698.57 490.816 1714.9 488.776 1718.98C487.143 1722.24 483.469 1731.02 469.592 1730.41C455.714 1729.8 455.306 1710.41 454.694 1702.04C453.265 1681.43 457.755 1661.22 468.571 1640C469.592 1637.96 481.837 1619.39 477.347 1604.49C473.265 1591.43 469.388 1571.22 475.51 1561.02C485.102 1544.9 502.041 1544.29 524.694 1540.61C540.408 1537.96 551.837 1530.2 562.857 1514.08C586.122 1480.2 673.674 1355.92 693.469 1327.14C698.776 1319.59 702.857 1309.8 704.694 1300.2C710.408 1271.22 727.959 1251.02 758.571 1238.57C765.102 1235.92 886.939 1134.08 886.939 1121.02C886.939 1111.63 873.674 1106.53 863.469 1103.67C862.041 1103.27 785.51 1083.47 750.612 1067.55C731.633 1058.98 717.347 1051.43 700.408 1039.18C677.347 1022.24 631.225 1030.41 624.49 1031.43C591.837 1036.12 567.551 1050.82 540 1069.39C535.102 1072.86 510 1081.84 497.347 1074.69C472.653 1060.61 418.367 1035.31 398.367 1004.9C389.184 990.816 390.408 967.959 396.939 942.653C417.959 883.878 448.98 857.551 506.327 841.633C547.143 827.551 629.388 824.49 668.98 821.429C671.633 820.612 759.388 818.571 821.02 786.939C862.041 765.918 983.878 684.898 1159.18 690.408C1230 692.653 1363.88 761.837 1415.51 763.878C1468.16 766.122 1494.49 760.204 1524.9 728.776C1533.47 719.796 1570 641.02 1535.51 597.959C1522.65 582.041 1509.39 567.755 1494.08 554.694C1473.47 537.143 1452.04 520.408 1433.47 500.612C1392.45 457.143 1379.8 397.755 1399.18 351.429C1411.63 318.98 1437.76 304.898 1475.51 312.245C1502.04 317.347 1521.43 341.225 1540.41 353.878C1551.02 361.02 1568.78 365.102 1580.41 367.959C1595.1 371.429 1606.12 382.653 1605.51 390.204C1604.9 397.755 1585.51 404.286 1566.94 402.857C1544.9 401.429 1507.14 401.225 1484.49 408.776C1469.39 414.286 1462.65 431.02 1467.14 447.551C1470.82 461.02 1499.59 485.102 1510.41 494.082C1532.04 511.837 1555.1 527.959 1573.06 550.204C1593.27 575.51 1603.88 604.286 1607.14 636.122C1612.04 684.898 1599.18 728.776 1574.29 770.408Z" fill="url(#ng)"/> </svg>'; +const OSTRICH = '<svg width="23" height="23" style="display:block" viewBox="0 0 2000 2000" fill="none" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="ng" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#B84FE8"/><stop offset="100%" stop-color="#6D28D9"/></linearGradient></defs><circle cx="1000" cy="1000" r="1000" fill="url(#ng)"/><g transform="translate(1000 1000) scale(0.82) translate(-1000 -1000)"><path d="M1574.29 770.408C1532.04 840.612 1445.92 888.367 1437.96 892.449C1421.84 901.02 1415.92 913.469 1416.94 932.041C1417.96 950.612 1413.47 1042.65 1357.35 1083.27C1328.57 1104.08 1212.45 1131.02 1159.18 1140C1131.84 1144.69 1125.51 1153.06 1110.41 1162.04C1090 1174.69 1017.55 1275.1 1005.31 1297.35C1006.94 1297.14 1233.67 1226.73 1243.88 1225.1C1270.82 1221.02 1291.22 1228.78 1304.49 1248.37C1317.14 1267.14 1329.59 1286.12 1342.04 1305.1C1346.53 1311.84 1350.82 1318.78 1355.1 1325.71L1359.59 1332.86C1361.22 1335.51 1363.47 1338.57 1364.49 1342.65C1365.31 1345.51 1367.14 1355.1 1361.63 1360.82C1356.53 1366.12 1347.76 1366.12 1343.06 1364.9C1338.57 1363.67 1331.84 1361.02 1326.33 1356.53L1318.78 1350.2C1312.86 1345.1 1301.63 1342.65 1292.86 1343.06C1282.65 1343.47 1269.18 1340.2 1262.65 1336.12C1250 1327.96 1246.73 1307.55 1246.73 1307.35C1245.1 1300.61 1240.2 1298.78 1235.71 1298.78C1227.55 1298.57 1221.22 1301.02 1214.69 1302.86C1184.9 1311.84 1155.31 1321.02 1125.51 1330.41L1082.86 1343.67C1061.84 1350.2 1040.82 1356.73 1020 1363.67C1014.29 1365.51 1008.78 1368.37 1002.86 1371.43C1000.41 1372.65 997.959 1373.88 995.51 1375.1C993.061 1376.33 990.408 1377.76 987.959 1379.18C982.449 1382.24 976.531 1385.51 970 1387.35C948.776 1393.67 930.204 1384.49 921.429 1363.27C915.714 1349.59 920.816 1322.45 923.878 1314.49C943.061 1264.69 1005.51 1170.82 1005.51 1170.2C1004.49 1169.8 936.327 1197.14 925.102 1205.71C894.286 1229.18 822.041 1284.69 821.02 1290C816.327 1314.69 801.02 1333.27 777.551 1342.45C760.612 1348.98 750.408 1362.45 740.408 1375.51C740.408 1375.51 559.388 1624.9 547.143 1638.37C543.469 1642.45 509.592 1677.96 500.612 1694.49C498.571 1698.57 490.816 1714.9 488.776 1718.98C487.143 1722.24 483.469 1731.02 469.592 1730.41C455.714 1729.8 455.306 1710.41 454.694 1702.04C453.265 1681.43 457.755 1661.22 468.571 1640C469.592 1637.96 481.837 1619.39 477.347 1604.49C473.265 1591.43 469.388 1571.22 475.51 1561.02C485.102 1544.9 502.041 1544.29 524.694 1540.61C540.408 1537.96 551.837 1530.2 562.857 1514.08C586.122 1480.2 673.674 1355.92 693.469 1327.14C698.776 1319.59 702.857 1309.8 704.694 1300.2C710.408 1271.22 727.959 1251.02 758.571 1238.57C765.102 1235.92 886.939 1134.08 886.939 1121.02C886.939 1111.63 873.674 1106.53 863.469 1103.67C862.041 1103.27 785.51 1083.47 750.612 1067.55C731.633 1058.98 717.347 1051.43 700.408 1039.18C677.347 1022.24 631.225 1030.41 624.49 1031.43C591.837 1036.12 567.551 1050.82 540 1069.39C535.102 1072.86 510 1081.84 497.347 1074.69C472.653 1060.61 418.367 1035.31 398.367 1004.9C389.184 990.816 390.408 967.959 396.939 942.653C417.959 883.878 448.98 857.551 506.327 841.633C547.143 827.551 629.388 824.49 668.98 821.429C671.633 820.612 759.388 818.571 821.02 786.939C862.041 765.918 983.878 684.898 1159.18 690.408C1230 692.653 1363.88 761.837 1415.51 763.878C1468.16 766.122 1494.49 760.204 1524.9 728.776C1533.47 719.796 1570 641.02 1535.51 597.959C1522.65 582.041 1509.39 567.755 1494.08 554.694C1473.47 537.143 1452.04 520.408 1433.47 500.612C1392.45 457.143 1379.8 397.755 1399.18 351.429C1411.63 318.98 1437.76 304.898 1475.51 312.245C1502.04 317.347 1521.43 341.225 1540.41 353.878C1551.02 361.02 1568.78 365.102 1580.41 367.959C1595.1 371.429 1606.12 382.653 1605.51 390.204C1604.9 397.755 1585.51 404.286 1566.94 402.857C1544.9 401.429 1507.14 401.225 1484.49 408.776C1469.39 414.286 1462.65 431.02 1467.14 447.551C1470.82 461.02 1499.59 485.102 1510.41 494.082C1532.04 511.837 1555.1 527.959 1573.06 550.204C1593.27 575.51 1603.88 604.286 1607.14 636.122C1612.04 684.898 1599.18 728.776 1574.29 770.408Z" fill="#fff"/></g></svg>'; 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; } +} |
