feat: add neon color picker for wallet management

- Replace muted Tailwind palette with 16-color neon palette
- Add clickable color dot in sidebar that opens a popup swatch grid
- Allow color override for all wallets (including embedded)
- Fix getColorForWallet to respect localStorage override
- Fix duplicate renameWallet in wallets.js
- Update Dockerfile to serve ESM modules via nginx
This commit is contained in:
Dione
2026-06-10 07:10:24 +00:00
parent 5716f34967
commit c573e58e0f
4 changed files with 173 additions and 7 deletions

View File

@ -10,11 +10,24 @@ docker build -t cbBTC-dashboard . && docker run -p 8080:80 cbBTC-dashboard
Or just open `index.html` in a browser (without the `/api/` proxy, only the Kraken BTC price chart will work). Or just open `index.html` in a browser (without the `/api/` proxy, only the Kraken BTC price chart will work).
## Architecture
The codebase is split into three vanilla ESM modules plus the single-page dashboard:
| Module | Purpose |
|---|---|
| `wallets.js` | **`WalletManager`** — localStorage state, chain-specific address validation, verification lifecycle |
| `verifier.js` | **`WalletVerifier`** — EIP-712 typed-data signing via `window.ethereum`, user rejection handling |
| `stream.js` | **`WalletStreamManager`** — single WebSocket with BroadcastChannel leader election, exponential backoff + jitter |
## File layout ## File layout
| File | Purpose | | File | Purpose |
|---|---| |---|---|
| `index.html` | Everything. Frontend, embedded chart data, inline JS logic. Edit here. | | `index.html` | Dashboard UI. Embedded chart data, ApexCharts, TailwindCSS. Loads modules via `<script type="module">`. |
| `wallets.js` | `WalletManager` — anonymous localStorage wallet state, add/remove/verify/revoke |
| `verifier.js` | `WalletVerifier``window.ethereum` connection + EIP-712 signature flow |
| `stream.js` | `WalletStreamManager` — single WS, cross-tab leader election, event routing |
| `Dockerfile` | 4 lines. Copies `index.html` and `default.conf` into `nginx:alpine`. | | `Dockerfile` | 4 lines. Copies `index.html` and `default.conf` into `nginx:alpine`. |
| `default.conf` | Reverse proxy: `/api/``http://192.168.1.102:8000/api/`. | | `default.conf` | Reverse proxy: `/api/``http://192.168.1.102:8000/api/`. |
| `aave_portfolio.md` / `wallet_portfolio.md` | Reference data dumps. NOT consumed by the app. | | `aave_portfolio.md` / `wallet_portfolio.md` | Reference data dumps. NOT consumed by the app. |
@ -34,11 +47,62 @@ Or just open `index.html` in a browser (without the `/api/` proxy, only the Krak
- **Tracked wallet:** `0x0c1a4a060e119f981412e323104d1c134d413dba` ("penguin", Base) - **Tracked wallet:** `0x0c1a4a060e119f981412e323104d1c134d413dba` ("penguin", Base)
- **Token decimals:** cbBTC=8, WETH=18, USDC=6 - **Token decimals:** cbBTC=8, WETH=18, USDC=6
## Module API
Each module is loaded via `<script type="module">` in `index.html`:
```js
import { WalletManager } from './wallets.js';
import { WalletVerifier } from './verifier.js';
import { WalletStreamManager } from './stream.js';
const wm = new WalletManager();
wm.loadWallets();
const verifier = new WalletVerifier(wm);
const stream = new WalletStreamManager(wm);
stream.connect();
```
### WalletManager
| Method | Description |
|---|---|
| `loadWallets()` | Reads from localStorage, sanitizes (always `isVerified: false`) |
| `addWallet(address, chain, nickname)` | Validates format, appends, persists |
| `removeWallet(address, chain)` | Drops from state, persists |
| `verifyWallet(address, chain, signature, messageData)` | Sets `isVerified: true` |
| `revokeVerification(address, chain)` | Resets to unverified |
| `findWallet(address, chain)` | Lookup helper |
| `getWallets()` | Returns deep copy of state |
### WalletVerifier
| Method | Description |
|---|---|
| `triggerWalletVerification(chain, nickname)` | Full flow: eth_requestAccounts → EIP-712 sign → add + verify |
| `connect()` | Quick connect check (no signature) |
EIP-712 domain: `{ name: "Anonymous Wallet Tracker", version: "1", chainId: 1 }`
### WalletStreamManager
| Method | Description |
|---|---|
| `connect()` | Triggers BroadcastChannel leader election → opens single WS |
| `disconnect()` | Closes WS + BC, cancels timers |
| `on(event, callback)` | Register callback per event type |
| `subscribeToNewWallet(wallet)` | Dynamic subscribe (leader → WS, follower → BC) |
| `unsubscribeFromWallet(address, chain)` | Dynamic unsubscribe |
Cross-tab leader election uses `BroadcastChannel("dione_shared_stream")` with a 200ms timeout. Leader-death broadcast triggers failover election. Reconnect uses exponential backoff (1s → 30s) with ±25% jitter.
## Gotchas ## Gotchas
- `apiBase = window.location.origin` — the dashboard relies on nginx proxy to reach the backend at `192.168.1.102:8000`. Running without Docker/proxy means the Aave table and price charts fail silently. - `apiBase = window.location.origin` — the dashboard relies on nginx proxy to reach the backend at `192.168.1.102:8000`. Running without Docker/proxy means the Aave table and price charts fail silently.
- No linting, no type-checking, no CI. Validate changes by opening the HTML in a browser. - No linting, no type-checking, no CI. Validate changes by opening the HTML in a browser.
- Adding chart data? Update the embedded arrays (`walletCumulData`, etc.) — they are plain JS arrays of `[timestampMs, value]` pairs. - Adding chart data? Update the embedded arrays (`walletCumulData`, etc.) — they are plain JS arrays of `[timestampMs, value]` pairs.
- `wallets.js` always forces `isVerified: false` on `loadWallets()` — persisted signatures are discarded. Verification must go through `WalletVerifier`.
## Coding Guidelines ## Coding Guidelines

View File

@ -1,4 +1,7 @@
FROM nginx:alpine FROM nginx:alpine
COPY index.html /usr/share/nginx/html/index.html COPY index.html /usr/share/nginx/html/index.html
COPY wallets.js /usr/share/nginx/html/wallets.js
COPY verifier.js /usr/share/nginx/html/verifier.js
COPY stream.js /usr/share/nginx/html/stream.js
COPY default.conf /etc/nginx/conf.d/default.conf COPY default.conf /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 80

View File

@ -47,6 +47,37 @@ window.WalletManager = WalletManager;
.wallet-filter-toggle { appearance: none; width: 12px; height: 12px; border-radius: 2px; border: 1px solid rgba(255,255,255,0.2); cursor: pointer; position: relative; transition: all 0.2s; } .wallet-filter-toggle { appearance: none; width: 12px; height: 12px; border-radius: 2px; border: 1px solid rgba(255,255,255,0.2); cursor: pointer; position: relative; transition: all 0.2s; }
.wallet-filter-toggle:checked { background: var(--wallet-color); border-color: transparent; box-shadow: 0 0 8px color-mix(in srgb, var(--wallet-color) 40%, transparent); } .wallet-filter-toggle:checked { background: var(--wallet-color); border-color: transparent; box-shadow: 0 0 8px color-mix(in srgb, var(--wallet-color) 40%, transparent); }
.wallet-color-dot {
width: 16px; height: 16px; border-radius: 50%; cursor: pointer;
flex-shrink: 0; transition: transform 0.15s, box-shadow 0.15s;
}
.wallet-color-dot:hover {
transform: scale(1.25); box-shadow: 0 0 10px var(--wallet-color);
}
.color-picker-overlay {
position: fixed; inset: 0; z-index: 70; display: none; background: rgba(0,0,0,0.5);
}
.color-picker-overlay.open { display: block; }
.color-picker-popup {
position: absolute; background: #090D14; border: 1px solid #1A1F2C;
border-radius: 12px; padding: 12px; box-shadow: 0 16px 40px rgba(0,0,0,0.7);
display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;
z-index: 80; width: 210px;
}
.color-picker-swatch {
width: 36px; height: 36px; border-radius: 50%; cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
border: 2px solid transparent;
}
.color-picker-swatch:hover {
transform: scale(1.2); border-color: rgba(255,255,255,0.3);
box-shadow: 0 0 12px var(--swatch-color);
}
.color-picker-swatch.selected {
border-color: #fff; box-shadow: 0 0 14px var(--swatch-color);
}
.ledger-header { display: flex; align-items: center; } .ledger-header { display: flex; align-items: center; }
.ledger-title-col { width: 260px; flex-shrink: 0; } .ledger-title-col { width: 260px; flex-shrink: 0; }
.col-timestamp { width: 140px; flex-shrink: 0; } .col-timestamp { width: 140px; flex-shrink: 0; }
@ -117,6 +148,9 @@ window.WalletManager = WalletManager;
</div> </div>
</div> </div>
<!-- Color Picker Popup -->
<div id="color-picker-overlay" class="color-picker-overlay" onclick="closeColorPicker()"></div>
<!-- Main Dashboard --> <!-- Main Dashboard -->
<div class="max-w-7xl mx-auto"> <div class="max-w-7xl mx-auto">
<!-- Top Nav Bar --> <!-- Top Nav Bar -->
@ -338,7 +372,7 @@ window.WalletManager = WalletManager;
const orangeBrandColor = '#FF7A00'; const orangeBrandColor = '#FF7A00';
const blueBrandColor = '#3b82f6'; const blueBrandColor = '#3b82f6';
const API_BASE = window.location.origin; const API_BASE = window.location.origin;
const WALLET_COLORS = ['#f7931a','#58a6ff','#22c55e','#f472b6','#a78bfa','#fb923c','#14b8a6','#8b5cf6','#facc15','#ec4899']; const WALLET_COLORS = ['#F7931A','#FF007F','#39FF14','#00FFFF','#CCFF00','#9D00FF','#FF0033','#00FFCC','#FF00FF','#007FFF','#DEFF0A','#FF5E00','#8A2BE2','#00FF66','#FF1493','#7B00FF'];
const TOKENS = {"cbBTC": {"decimals": 8, "priceSymbol": "BTC"}, "WETH": {"decimals": 18, "priceSymbol": "WETH"}, "USDC": {"decimals": 6, "priceSymbol": "USDC"}}; const TOKENS = {"cbBTC": {"decimals": 8, "priceSymbol": "BTC"}, "WETH": {"decimals": 18, "priceSymbol": "WETH"}, "USDC": {"decimals": 6, "priceSymbol": "USDC"}};
/* Embedded data for hardcoded wallets */ /* Embedded data for hardcoded wallets */
@ -388,10 +422,10 @@ function findEmbeddedId(address) {
/* Assigned color index for dynamic wallets */ /* Assigned color index for dynamic wallets */
let _colorIdx = 0; let _colorIdx = 0;
function getColorForWallet(address) { function getColorForWallet(address) {
const embedded = findEmbeddedId(address);
if (embedded && walletsMetadata[embedded].color) return walletsMetadata[embedded].color;
const c = localStorage.getItem('cbbtc_color_' + address); const c = localStorage.getItem('cbbtc_color_' + address);
if (c) return c; if (c) return c;
const embedded = findEmbeddedId(address);
if (embedded && walletsMetadata[embedded].color) return walletsMetadata[embedded].color;
const color = WALLET_COLORS[_colorIdx % WALLET_COLORS.length]; const color = WALLET_COLORS[_colorIdx % WALLET_COLORS.length];
_colorIdx++; _colorIdx++;
localStorage.setItem('cbbtc_color_' + address, color); localStorage.setItem('cbbtc_color_' + address, color);
@ -399,8 +433,6 @@ function getColorForWallet(address) {
} }
function setColorForWallet(address, color) { function setColorForWallet(address, color) {
const embedded = findEmbeddedId(address);
if (embedded) return; /* embedded wallets have fixed colors */
localStorage.setItem('cbbtc_color_' + address, color); localStorage.setItem('cbbtc_color_' + address, color);
renderAll(); renderAll();
} }
@ -417,6 +449,55 @@ function getNextColor(address) {
return WALLET_COLORS[nextIdx]; return WALLET_COLORS[nextIdx];
} }
/* Color Picker */
let _colorPickerTargetAddr = null;
function openColorPicker(event, address) {
event.stopPropagation();
_colorPickerTargetAddr = address;
const overlay = document.getElementById('color-picker-overlay');
const popup = document.getElementById('color-picker-popup');
if (popup) popup.remove();
const newPopup = document.createElement('div');
newPopup.id = 'color-picker-popup';
newPopup.className = 'color-picker-popup';
newPopup.style.left = event.clientX + 'px';
newPopup.style.top = event.clientY + 'px';
const currentColor = getColorForWallet(address);
WALLET_COLORS.forEach((c, i) => {
const swatch = document.createElement('div');
swatch.className = 'color-picker-swatch' + (c.toLowerCase() === currentColor.toLowerCase() ? ' selected' : '');
swatch.style.setProperty('--swatch-color', c);
swatch.style.background = c;
swatch.addEventListener('click', (e) => {
e.stopPropagation();
selectColor(c);
closeColorPicker();
});
newPopup.appendChild(swatch);
});
document.body.appendChild(newPopup);
const rect = newPopup.getBoundingClientRect();
if (rect.right > window.innerWidth) newPopup.style.left = (event.clientX - rect.width) + 'px';
if (rect.bottom > window.innerHeight) newPopup.style.top = (event.clientY - rect.height) + 'px';
overlay.classList.add('open');
}
function closeColorPicker() {
document.getElementById('color-picker-overlay').classList.remove('open');
const popup = document.getElementById('color-picker-popup');
if (popup) popup.remove();
_colorPickerTargetAddr = null;
}
function selectColor(color) {
if (_colorPickerTargetAddr) {
setColorForWallet(_colorPickerTargetAddr, color);
}
}
/* Wallet filter state — stores UNCHECKED addresses. Empty = all checked. */ /* Wallet filter state — stores UNCHECKED addresses. Empty = all checked. */
function getWalletFilter() { function getWalletFilter() {
return JSON.parse(localStorage.getItem('cbbtc_ledger_wallets') || '[]'); return JSON.parse(localStorage.getItem('cbbtc_ledger_wallets') || '[]');
@ -503,7 +584,7 @@ function renderSidebar() {
const nick = w.nickname || shortAddr; const nick = w.nickname || shortAddr;
html += '<div class="flex items-center justify-between bg-[#05070B] border border-[#1A1F2C]/40 rounded-lg px-3 py-2.5">' + html += '<div class="flex items-center justify-between bg-[#05070B] border border-[#1A1F2C]/40 rounded-lg px-3 py-2.5">' +
'<div class="flex items-center gap-2.5 min-w-0 flex-1">' + '<div class="flex items-center gap-2.5 min-w-0 flex-1">' +
'<div onclick="cycleWalletColor(\'' + w.address + '\')" class="wallet-filter-toggle cursor-pointer flex-shrink-0" style="--wallet-color:' + color + ';" title="Click to change color"></div>' + '<div onclick="openColorPicker(event, \'' + w.address + '\')" class="wallet-color-dot" style="--wallet-color:' + color + ';background:' + color + ';" title="Click to change color"></div>' +
'<div class="min-w-0 flex-1">' + '<div class="min-w-0 flex-1">' +
'<input class="rename-nick-input bg-transparent text-sm font-medium text-white truncate border border-transparent focus:border-[#FF7A00]/50 rounded px-1 outline-none transition block w-full" value="' + nick + '" data-addr="' + w.address + '" data-chain="' + w.chain + '" onblur="handleRenameNickname(this)" onkeydown="if(event.key===\'Enter\')this.blur()">' + '<input class="rename-nick-input bg-transparent text-sm font-medium text-white truncate border border-transparent focus:border-[#FF7A00]/50 rounded px-1 outline-none transition block w-full" value="' + nick + '" data-addr="' + w.address + '" data-chain="' + w.chain + '" onblur="handleRenameNickname(this)" onkeydown="if(event.key===\'Enter\')this.blur()">' +
'<div class="text-[10px] text-gray-500 font-mono truncate">' + shortAddr + '</div>' + '<div class="text-[10px] text-gray-500 font-mono truncate">' + shortAddr + '</div>' +

View File

@ -266,6 +266,24 @@ export class WalletManager {
); );
} }
/**
* Rename a wallet's nickname.
*
* @param {string} address
* @param {ChainId} chain
* @param {string} nickname
* @returns {{ success: true } | { success: false, error: string }}
*/
renameWallet(address, chain, nickname) {
const w = this.findWallet(address, chain);
if (!w) {
return { success: false, error: 'Wallet not found' };
}
w.nickname = String(nickname).trim();
this._persist();
return { success: true };
}
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
/* Verification */ /* Verification */
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */