feat(rwallet): timeline-first UX overhaul with proportional rivers

- Fix USD estimation: add NATIVE_APPROX_USD price table for ETH, MATIC,
  BNB, AVAX, xDAI, CELO, GNO; unknown tokens fall back to $0 instead of
  raw token amounts (fixes wildly wrong river widths)
- Fix scroll hijacking: only intercept Ctrl+wheel (pinch-to-zoom) on
  timeline, flow chart, and sankey; normal two-finger scroll passes through
- Collapse address bar to compact chip after wallet loads with Save/Change
- Promote watchlist as horizontal chip selector above dashboard; merge
  example wallets as dashed "suggested" chips when watchlist is empty
- Default to timeline view after wallet detection (auto-loads transfers)
- Move balance/transaction tables to Details modal (pill button, overlay
  with backdrop blur) — stats cards hidden in viz views since D3 shows them

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-23 18:16:06 -07:00
parent 1282ba5325
commit 51be476694
3 changed files with 286 additions and 84 deletions

View File

@ -182,6 +182,10 @@ class FolkWalletViewer extends HTMLElement {
private sandboxDays = 365;
private sandboxComparisons: ProtocolComparison[] = [];
// UX state
private addressBarExpanded = false;
private detailsModalOpen = false;
// Visualization state
private activeView: ViewTab = "balances";
private transfers: Map<string, any> | null = null;
@ -609,7 +613,6 @@ class FolkWalletViewer extends HTMLElement {
this.allChainBalances = new Map();
this.chainFilter = null;
this.walletType = "";
this.activeView = "balances";
this.transfers = null;
this.vizData = {};
this.render();
@ -642,6 +645,19 @@ class FolkWalletViewer extends HTMLElement {
}
this.loading = false;
this.addressBarExpanded = false;
// Default to timeline view and auto-load transfers when wallet data is available
if (this.hasData()) {
const showViz = this.walletType === "safe" || this.isDemo || (this.isAuthenticated && this.crdtBalances.length > 0);
if (showViz) {
this.activeView = "timeline";
this.render();
this.loadTransfers();
return;
}
}
this.render();
}
@ -997,6 +1013,7 @@ class FolkWalletViewer extends HTMLElement {
const input = this.shadow.querySelector("#address-input") as HTMLInputElement;
if (input) {
this.address = input.value.trim();
this.addressBarExpanded = false;
const url = new URL(window.location.href);
url.searchParams.set("address", this.address);
window.history.replaceState({}, "", url.toString());
@ -1947,6 +1964,85 @@ class FolkWalletViewer extends HTMLElement {
.sandbox-balance { display: none; }
}
/* ── Compact address bar ── */
.compact-address-bar {
display: flex; align-items: center; gap: 8px; margin-bottom: 12px;
max-width: 640px; margin-left: auto; margin-right: auto;
padding: 8px 14px; border-radius: 10px;
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-subtle);
}
.compact-addr-chip {
display: flex; align-items: center; gap: 4px; flex: 1; min-width: 0;
font-family: monospace; font-size: 14px; font-weight: 600; color: var(--rs-text-primary);
}
.compact-btn {
padding: 5px 12px; border-radius: 6px; border: 1px solid var(--rs-border);
background: transparent; color: var(--rs-text-secondary); cursor: pointer;
font-size: 12px; font-weight: 500; transition: all 0.2s; white-space: nowrap;
}
.compact-btn:hover { border-color: var(--rs-accent); color: var(--rs-accent); }
/* ── Watchlist chips ── */
.watchlist-bar {
display: flex; flex-wrap: wrap; gap: 6px; align-items: center;
margin-bottom: 16px; max-width: 640px; margin-left: auto; margin-right: auto;
}
.watchlist-chip {
display: inline-flex; align-items: center; gap: 4px;
padding: 4px 10px; border-radius: 16px;
border: 1px solid var(--rs-border-subtle); background: var(--rs-bg-surface);
color: var(--rs-text-secondary); cursor: pointer; font-size: 12px;
transition: all 0.2s; white-space: nowrap;
}
.watchlist-chip:hover { border-color: var(--rs-accent); color: var(--rs-accent); }
.watchlist-chip.active { border-color: var(--rs-accent); background: rgba(20,184,166,0.1); color: var(--rs-accent); }
.watchlist-chip.suggested { border-style: dashed; color: var(--rs-text-muted); }
.watchlist-chip.suggested:hover { border-style: solid; }
.watchlist-chip.add-chip { padding: 4px 8px; font-size: 14px; font-weight: 700; }
.chip-label { font-weight: 500; }
.chip-addr { font-family: monospace; font-size: 10px; color: var(--rs-text-muted); }
.chip-remove {
font-size: 14px; line-height: 1; padding: 0 2px; margin-left: 2px;
color: var(--rs-text-muted); cursor: pointer;
}
.chip-remove:hover { color: var(--rs-error); }
/* ── Details modal ── */
.details-modal-overlay {
position: fixed; inset: 0; z-index: 1000;
background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center;
padding: 20px;
}
.details-modal {
background: var(--rs-bg, #0f0f1a); border: 1px solid var(--rs-border-subtle);
border-radius: 16px; max-width: 900px; width: 100%;
max-height: 80vh; display: flex; flex-direction: column;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
}
.details-modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px; border-bottom: 1px solid var(--rs-border-subtle);
}
.details-modal-header h3 { margin: 0; font-size: 16px; color: var(--rs-text-primary); }
.details-modal-header button {
background: none; border: none; color: var(--rs-text-muted); cursor: pointer;
font-size: 20px; padding: 4px 8px; border-radius: 6px;
}
.details-modal-header button:hover { color: var(--rs-text-primary); background: var(--rs-bg-hover); }
.details-modal-body { overflow-y: auto; padding: 20px; }
/* ── Viz wrapper with details button ── */
.viz-wrapper { position: relative; }
.details-btn {
position: absolute; top: 8px; right: 8px; z-index: 5;
padding: 5px 14px; border-radius: 16px;
border: 1px solid var(--rs-border); background: var(--rs-bg-surface);
color: var(--rs-text-secondary); cursor: pointer; font-size: 12px;
font-weight: 500; transition: all 0.2s; backdrop-filter: blur(4px);
}
.details-btn:hover { border-color: var(--rs-accent); color: var(--rs-accent); }
@media (max-width: 768px) {
.hero-title { font-size: 22px; }
.balance-table { display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; }
@ -1959,6 +2055,8 @@ class FolkWalletViewer extends HTMLElement {
}
@media (max-width: 640px) {
.view-tab { padding: 6px 10px; font-size: 12px; }
.compact-address-bar { flex-wrap: wrap; }
.watchlist-bar { gap: 4px; }
}
@media (max-width: 480px) {
.features { grid-template-columns: 1fr; }
@ -2813,6 +2911,8 @@ class FolkWalletViewer extends HTMLElement {
Local
</div>` : "";
const isVizView = this.activeView !== "balances" && this.activeView !== "yield";
return `
<div class="chains">
<div class="chain-btn ${this.chainFilter === null ? "active" : ""}"
@ -2823,7 +2923,7 @@ class FolkWalletViewer extends HTMLElement {
${localBtn}
</div>
<div class="stats">
${!isVizView ? `<div class="stats">
<div class="stat-card">
<div class="stat-label">Total Value</div>
<div class="stat-value">${this.formatUSD(String(totalUSD))}</div>
@ -2840,7 +2940,7 @@ class FolkWalletViewer extends HTMLElement {
<div class="stat-label">Address</div>
<div class="stat-value" style="font-size:13px;font-family:monospace">${this.shortenAddress(this.address)}</div>
</div>
</div>
</div>` : ""}
${this.renderViewTabs()}
@ -2848,50 +2948,115 @@ class FolkWalletViewer extends HTMLElement {
? this.renderBalanceTable() + this.renderPaymentActions()
: this.activeView === "yield"
? this.renderYieldTab()
: `<div class="viz-container" id="viz-container">
${this.transfersLoading ? '<div class="loading"><span class="spinner"></span> Loading transfer data...</div>' : ""}
</div>
${this.renderTransactionTables()}`
: `<div class="viz-wrapper">
<button class="details-btn" data-action="open-details">Details</button>
<div class="viz-container" id="viz-container">
${this.transfersLoading ? '<div class="loading"><span class="spinner"></span> Loading transfer data...</div>' : ""}
</div>
</div>`
}`;
}
private renderCompactAddressBar(): string {
const typeLabel = this.walletType === "safe" ? "Safe Multisig" : this.walletType === "eoa" ? "EOA" : "";
const isWatched = this.watchedAddresses.some(w => w.address.toLowerCase() === this.address.toLowerCase());
return `
<div class="compact-address-bar">
<span class="compact-addr-chip">
<span class="compact-addr-text">${this.shortenAddress(this.address)}</span>
${typeLabel ? `<span class="wallet-badge ${this.walletType}" style="margin-left:6px;font-size:10px;padding:2px 6px">${typeLabel}</span>` : ""}
</span>
${!isWatched ? `<button class="compact-btn" data-action="save-wallet" title="Save to watchlist">+ Save</button>` : ""}
<button class="compact-btn" data-action="change-wallet">Change</button>
</div>`;
}
private renderWatchlistChips(): string {
const chips = this.watchedAddresses.map(w => {
const isActive = this.address && w.address.toLowerCase() === this.address.toLowerCase();
return `<button class="watchlist-chip ${isActive ? "active" : ""}" data-watch-address="${w.address}" title="${w.address}">
<span class="chip-label">${this.esc(w.label || w.address.slice(0, 8) + '...')}</span>
<span class="chip-addr">${w.address.slice(0, 6)}...${w.address.slice(-4)}</span>
<span class="chip-remove" data-watch-key="${w.chain}:${w.address.toLowerCase()}">&times;</span>
</button>`;
}).join('');
// Show example wallets as suggestions when watchlist is empty and no wallet loaded
const showSuggestions = this.watchedAddresses.length === 0 && !this.hasData() && !this.loading;
const suggestions = showSuggestions ? EXAMPLE_WALLETS.map(w => `
<button class="watchlist-chip suggested" data-address="${w.address}" title="${w.name}">
<span class="chip-label">${w.name}</span>
<span class="chip-addr">${w.address.slice(0, 6)}...${w.address.slice(-4)}</span>
</button>
`).join('') : '';
if (!chips && !suggestions) {
if (!this.hasData()) return `<div class="watchlist-bar"><span style="font-size:0.8rem;color:var(--rs-text-muted)">Save wallets to quickly switch between them</span></div>`;
return '';
}
return `
<div class="watchlist-bar">
${chips}${suggestions}
<button class="watchlist-chip add-chip" data-action="add-watch" title="Watch a new address">+</button>
</div>`;
}
private renderDetailsModal(): string {
if (!this.detailsModalOpen) return '';
return `
<div class="details-modal-overlay" data-action="close-details">
<div class="details-modal" data-stop-propagation>
<div class="details-modal-header">
<h3>Wallet Details</h3>
<button data-action="close-details">&times;</button>
</div>
<div class="details-modal-body">
${this.renderBalanceTable()}
${this.renderTransactionTables()}
</div>
</div>
</div>`;
}
private renderVisualizerTab(): string {
// Yield view is standalone — skip wallet UI entirely
if (this.activeView === "yield") {
return `${this.renderYieldStandaloneHeader()}${this.renderYieldTab()}`;
}
const hasData = this.hasData();
const showFullAddressBar = !hasData || this.addressBarExpanded;
return `
${this.renderHero()}
<form class="address-bar" id="address-form">
<input id="address-input" type="text" placeholder="Enter any wallet or Safe address (0x...)"
value="${this.address}" spellcheck="false">
<button type="submit">Load</button>
</form>
${showFullAddressBar ? `
<form class="address-bar" id="address-form">
<input id="address-input" type="text" placeholder="Enter any wallet or Safe address (0x...)"
value="${this.address}" spellcheck="false">
<button type="submit">Load</button>
</form>
<div class="controls-row">
<div class="testnet-toggle ${this.includeTestnets ? "active" : ""}" id="testnet-toggle">
<div class="toggle-track"><div class="toggle-thumb"></div></div>
<span>Include testnets</span>
</div>
<button class="view-tab" id="btn-tour" style="margin-left:auto;font-size:0.78rem;padding:4px 10px">Tour</button>
${this.walletType ? `
<div class="wallet-badge ${this.walletType}">
${this.walletType === "safe" ? "&#9939; Safe Multisig" : "&#128100; EOA Wallet"}
<div class="controls-row">
<div class="testnet-toggle ${this.includeTestnets ? "active" : ""}" id="testnet-toggle">
<div class="toggle-track"><div class="toggle-thumb"></div></div>
<span>Include testnets</span>
</div>
` : ""}
</div>
<button class="view-tab" id="btn-tour" style="margin-left:auto;font-size:0.78rem;padding:4px 10px">Tour</button>
</div>
` : this.renderCompactAddressBar()}
${this.renderWatchlistChips()}
${this.renderSupportedChains()}
${this.error ? `<div class="error">${this.esc(this.error)}</div>` : ""}
${this.loading ? '<div class="loading"><span class="spinner"></span> Detecting wallet across chains...</div>' : ""}
${this.renderFeatures()}
${this.renderExamples()}
${this.renderWatchlist()}
${!hasData && !this.loading && this.watchedAddresses.length === 0 ? this.renderFeatures() : ""}
${this.renderDashboard()}
${this.renderDetailsModal()}
`;
}
@ -2918,19 +3083,66 @@ class FolkWalletViewer extends HTMLElement {
});
});
this.shadow.querySelectorAll(".example-item").forEach((item) => {
item.addEventListener("click", () => {
const addr = (item as HTMLElement).dataset.address!;
const input = this.shadow.querySelector("#address-input") as HTMLInputElement;
if (input) input.value = addr;
// Compact address bar actions
this.shadow.querySelector('[data-action="change-wallet"]')?.addEventListener('click', () => {
this.addressBarExpanded = true;
this.render();
const input = this.shadow.querySelector("#address-input") as HTMLInputElement;
if (input) { input.value = this.address; input.focus(); input.select(); }
});
this.shadow.querySelector('[data-action="save-wallet"]')?.addEventListener('click', () => {
const label = prompt('Label (optional):') || '';
const chain = this.selectedChain || '1';
this.addToWatchlist(this.address, chain, label);
});
// Watchlist chips: click to load address
this.shadow.querySelectorAll<HTMLElement>('.watchlist-chip[data-watch-address]').forEach(el => {
el.addEventListener('click', (e) => {
// Don't navigate if clicking the remove button
if ((e.target as HTMLElement).classList.contains('chip-remove')) return;
const addr = el.dataset.watchAddress;
if (!addr) return;
this.address = addr;
const url = new URL(window.location.href);
url.searchParams.set("address", addr);
window.history.replaceState({}, "", url.toString());
url.searchParams.set('address', addr);
window.history.replaceState({}, '', url.toString());
this.detectChains();
});
});
// Watchlist chips: suggested examples
this.shadow.querySelectorAll<HTMLElement>('.watchlist-chip.suggested[data-address]').forEach(el => {
el.addEventListener('click', () => {
const addr = el.dataset.address;
if (!addr) return;
this.address = addr;
const url = new URL(window.location.href);
url.searchParams.set('address', addr);
window.history.replaceState({}, '', url.toString());
this.detectChains();
});
});
// Watchlist: remove chip
this.shadow.querySelectorAll<HTMLElement>('.chip-remove').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const key = btn.dataset.watchKey;
if (key) this.removeFromWatchlist(key);
});
});
// Watchlist: add
this.shadow.querySelector('[data-action="add-watch"]')?.addEventListener('click', () => {
const addr = prompt('Wallet address to watch:');
if (!addr) return;
const label = prompt('Label (optional):') || '';
const chain = this.selectedChain || '1';
this.addToWatchlist(addr, chain, label);
});
// View tab listeners (skip tour button which has no data-view)
this.shadow.querySelectorAll(".view-tab[data-view]").forEach((tab) => {
tab.addEventListener("click", () => {
@ -2941,6 +3153,20 @@ class FolkWalletViewer extends HTMLElement {
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
// Details modal
this.shadow.querySelector('[data-action="open-details"]')?.addEventListener('click', () => {
this.detailsModalOpen = true;
this.render();
});
this.shadow.querySelectorAll('[data-action="close-details"]').forEach(el => {
el.addEventListener('click', (e) => {
// Don't close when clicking inside the modal body
if ((e.target as HTMLElement).closest('[data-stop-propagation]') && !(e.target as HTMLElement).hasAttribute('data-action')) return;
this.detailsModalOpen = false;
this.render();
});
});
// Sandbox listeners
this.shadow.querySelector("[data-sandbox-toggle]")?.addEventListener("click", () => {
this.sandboxActive = !this.sandboxActive;
@ -2980,39 +3206,6 @@ class FolkWalletViewer extends HTMLElement {
});
});
// Watchlist: add
this.shadow.querySelector('[data-action="add-watch"]')?.addEventListener('click', () => {
const addr = prompt('Wallet address to watch:');
if (!addr) return;
const label = prompt('Label (optional):') || '';
const chain = this.selectedChain || '1';
this.addToWatchlist(addr, chain, label);
});
// Watchlist: click to load
this.shadow.querySelectorAll<HTMLElement>('.watch-item').forEach(el => {
el.addEventListener('click', () => {
const addr = el.dataset.watchAddress;
if (!addr) return;
const input = this.shadow.querySelector('#address-input') as HTMLInputElement;
if (input) input.value = addr;
this.address = addr;
const url = new URL(window.location.href);
url.searchParams.set('address', addr);
window.history.replaceState({}, '', url.toString());
this.detectChains();
});
});
// Watchlist: remove
this.shadow.querySelectorAll<HTMLElement>('.watch-remove').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const key = btn.dataset.watchKey;
if (key) this.removeFromWatchlist(key);
});
});
// Draw visualization if active
if (this.activeView !== "balances" && this.activeView !== "yield" && this.hasData()) {
requestAnimationFrame(() => this.drawActiveVisualization());

View File

@ -134,8 +134,20 @@ const STABLECOINS = new Set([
"GHO", "PYUSD", "DOLA", "Yield-USD", "yUSD",
]);
// Approximate USD prices for major non-stablecoin tokens (updated periodically)
const NATIVE_APPROX_USD: Record<string, number> = {
ETH: 2500, WETH: 2500, stETH: 2500, cbETH: 2500, rETH: 2800, wstETH: 2900,
MATIC: 0.40, POL: 0.40, WMATIC: 0.40,
BNB: 600, WBNB: 600,
AVAX: 35, WAVAX: 35,
xDAI: 1, WXDAI: 1,
CELO: 0.50, GNO: 250,
};
export function estimateUSD(value: number, symbol: string): number | null {
if (STABLECOINS.has(symbol)) return value;
const price = NATIVE_APPROX_USD[symbol];
if (price !== undefined) return value * price;
return null;
}
@ -172,7 +184,7 @@ export function transformToTimelineData(
type: "in",
amount: value,
token: symbol,
usd: usd !== null ? usd : value,
usd: usd !== null ? usd : 0,
hasUsdEstimate: usd !== null,
chain: chainName,
chainId,
@ -239,7 +251,7 @@ export function transformToTimelineData(
}
for (const t of txTransfers) {
const usd = t.usd !== null ? t.usd : t.value;
const usd = t.usd !== null ? t.usd : 0;
timeline.push({
date: tx.executionDate,
type: "out",
@ -445,7 +457,7 @@ export function transformToMultichainData(
if (value <= 0) continue;
const usd = estimateUSD(value, symbol);
const usdVal = usd !== null ? usd : value;
const usdVal = usd !== null ? usd : 0;
chainTransfers++;
chainInflow += usdVal;
if (transfer.from) {
@ -546,7 +558,7 @@ export function transformToMultichainData(
for (const t of outTransfers) {
const usd = estimateUSD(t.value, t.symbol);
const usdVal = usd !== null ? usd : t.value;
const usdVal = usd !== null ? usd : 0;
chainOutflow += usdVal;
if (t.to) {
chainAddresses.add(t.to.toLowerCase());

View File

@ -400,20 +400,15 @@ export function renderTimeline(
.on("end", () => svg.style("cursor", "grab"));
svg.on("wheel", function(event: any) {
if (!event.ctrlKey) return; // let normal scroll pass through to page
event.preventDefault();
const currentTransform = d3.zoomTransform(svg.node());
if (Math.abs(event.deltaX) > Math.abs(event.deltaY) || event.shiftKey) {
const panAmount = event.deltaX !== 0 ? event.deltaX : event.deltaY;
const newTransform = currentTransform.translate(-panAmount * 0.5, 0);
svg.call(zoom.transform, newTransform);
} else {
const scaleFactor = event.deltaY > 0 ? 0.9 : 1.1;
const [mouseX] = d3.pointer(event);
const newScale = Math.max(0.5, Math.min(20, currentTransform.k * scaleFactor));
const newX = mouseX - (mouseX - currentTransform.x) * (newScale / currentTransform.k);
const newTransform = d3.zoomIdentity.translate(newX, 0).scale(newScale);
svg.call(zoom.transform, newTransform);
}
const scaleFactor = event.deltaY > 0 ? 0.9 : 1.1;
const [mouseX] = d3.pointer(event);
const newScale = Math.max(0.5, Math.min(20, currentTransform.k * scaleFactor));
const newX = mouseX - (mouseX - currentTransform.x) * (newScale / currentTransform.k);
const newTransform = d3.zoomIdentity.translate(newX, 0).scale(newScale);
svg.call(zoom.transform, newTransform);
}, { passive: false });
svg.call(zoom);
@ -537,9 +532,10 @@ export function renderFlowChart(
.attr("fill", COLORS.red).attr("font-size", "9px").text(`-${flow.value.toLocaleString()} ${flow.token}`);
});
// Zoom
// Zoom (only intercept Ctrl+wheel / pinch-to-zoom, let normal scroll pass through)
const flowZoom = d3.zoom()
.scaleExtent([0.3, 5])
.filter((event: any) => event.type !== "wheel" || event.ctrlKey)
.on("zoom", (event: any) => g.attr("transform", event.transform))
.on("start", () => svg.style("cursor", "grabbing"))
.on("end", () => svg.style("cursor", "grab"));
@ -641,9 +637,10 @@ export function renderSankey(
.style("font-weight", (d: any) => d.type === "wallet" ? "bold" : "normal")
.style("fill", "#e0e0e0");
// Zoom
// Zoom (only intercept Ctrl+wheel / pinch-to-zoom, let normal scroll pass through)
const sankeyZoom = d3.zoom()
.scaleExtent([0.3, 5])
.filter((event: any) => event.type !== "wheel" || event.ctrlKey)
.on("zoom", (event: any) => zoomGroup.attr("transform", event.transform))
.on("start", () => svg.style("cursor", "grabbing"))
.on("end", () => svg.style("cursor", "grab"));