Web3 — Portfolio Value Chart (PnL)
A wallet portfolio value card built around a hand-rolled SVG chart: a large animated count-up total in JetBrains Mono, absolute and percentage PnL that recolors green or red per range, and a smooth Catmull-Rom line with a gradient glow stroke over a fading area fill. Range tabs (1H / 1D / 1W / 1M / 1Y / ALL) regenerate a seeded mock series and replay a draw-in animation, while a crosshair tooltip tracks the pointer with the exact value and timestamp. An allocation bar and legend round out the card. No chart libraries — pure vanilla JS.
MCP
Code
:root {
--bg: #0a0b0f;
--surface: #13151c;
--surface-2: #1b1e27;
--elevated: #23262f;
--text: #e9ecf2;
--muted: #8a90a2;
--line: rgba(255, 255, 255, 0.08);
--line-2: rgba(255, 255, 255, 0.16);
--accent: #7c5cff;
--accent-2: #00e0c6;
--accent-glow: rgba(124, 92, 255, 0.45);
--pos: #26d07c;
--neg: #ff4d6d;
--warn: #ffb347;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--r-pill: 999px;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(900px 480px at 80% -10%, rgba(124, 92, 255, 0.14), transparent 60%),
radial-gradient(700px 420px at 5% 110%, rgba(0, 224, 198, 0.08), transparent 60%),
var(--bg);
color: var(--text);
font-family: "Space Grotesk", system-ui, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
display: grid;
place-items: center;
padding: 28px 16px;
}
.mono {
font-family: "JetBrains Mono", ui-monospace, monospace;
}
.muted {
color: var(--muted);
}
.page {
width: 100%;
max-width: 760px;
}
/* ---------- Card ---------- */
.chart-card {
position: relative;
border-radius: var(--r-lg);
padding: 26px 26px 24px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01)), var(--surface);
border: 1px solid transparent;
background-clip: padding-box;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
overflow: hidden;
isolation: isolate;
}
/* gradient border */
.chart-card::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(135deg, rgba(124, 92, 255, 0.55), rgba(255, 255, 255, 0.08) 35%, rgba(0, 224, 198, 0.45));
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
z-index: 1;
}
.card-glow {
position: absolute;
top: -120px;
right: -80px;
width: 320px;
height: 320px;
border-radius: 50%;
background: radial-gradient(circle, var(--accent-glow), transparent 70%);
opacity: 0.35;
filter: blur(20px);
pointer-events: none;
z-index: 0;
}
.chart-card > *:not(.card-glow) {
position: relative;
z-index: 2;
}
/* ---------- Header ---------- */
.card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.wallet-row {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.wallet-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--pos);
box-shadow: 0 0 8px rgba(38, 208, 124, 0.8);
}
.wallet-addr {
font-size: 0.8rem;
color: var(--muted);
letter-spacing: 0.02em;
}
.chain-pill {
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 3px 10px;
border-radius: var(--r-pill);
color: var(--accent-2);
background: rgba(0, 224, 198, 0.1);
border: 1px solid rgba(0, 224, 198, 0.28);
}
.card-title {
margin: 0;
font-size: 1.02rem;
font-weight: 500;
color: var(--muted);
}
.btn-ghost {
display: inline-flex;
align-items: center;
gap: 7px;
font: 500 0.82rem "Space Grotesk", sans-serif;
color: var(--text);
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--r-pill);
padding: 8px 16px;
cursor: pointer;
transition: border-color 0.18s ease, background 0.18s ease, transform 0.12s ease;
}
.btn-ghost:hover {
background: var(--elevated);
border-color: var(--line-2);
}
.btn-ghost:active {
transform: scale(0.97);
}
.btn-ghost.is-spinning svg {
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ---------- Value + PnL ---------- */
.value-block {
margin-top: 14px;
}
.big-value {
margin: 0;
font-size: clamp(1.9rem, 5.4vw, 2.7rem);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.15;
text-shadow: 0 0 30px rgba(124, 92, 255, 0.25);
}
.pnl-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-top: 6px;
font-size: 0.92rem;
}
.pnl-abs {
font-weight: 500;
}
.pnl-pct {
display: inline-flex;
align-items: center;
gap: 4px;
font-weight: 700;
font-size: 0.8rem;
padding: 3px 10px;
border-radius: var(--r-pill);
}
.pnl-arrow {
transition: transform 0.25s ease;
}
.pnl-row.is-pos .pnl-abs,
.pnl-row.is-pos .pnl-pct {
color: var(--pos);
}
.pnl-row.is-pos .pnl-pct {
background: rgba(38, 208, 124, 0.12);
border: 1px solid rgba(38, 208, 124, 0.3);
}
.pnl-row.is-neg .pnl-abs,
.pnl-row.is-neg .pnl-pct {
color: var(--neg);
}
.pnl-row.is-neg .pnl-pct {
background: rgba(255, 77, 109, 0.12);
border: 1px solid rgba(255, 77, 109, 0.3);
}
.pnl-row.is-neg .pnl-arrow {
transform: rotate(180deg);
}
.pnl-range {
font-size: 0.82rem;
}
/* ---------- Range tabs ---------- */
.range-tabs {
display: flex;
gap: 4px;
margin-top: 20px;
padding: 4px;
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--r-pill);
width: fit-content;
}
.range-tab {
font: 600 0.78rem "Space Grotesk", sans-serif;
letter-spacing: 0.04em;
color: var(--muted);
background: transparent;
border: 0;
border-radius: var(--r-pill);
padding: 7px 15px;
cursor: pointer;
transition: color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease;
}
.range-tab:hover {
color: var(--text);
}
.range-tab.is-active {
color: #fff;
background: linear-gradient(135deg, var(--accent), #5a3de0);
box-shadow: 0 4px 16px var(--accent-glow);
}
/* focus rings */
.range-tab:focus-visible,
.btn-ghost:focus-visible {
outline: 2px solid var(--accent-2);
outline-offset: 2px;
}
/* ---------- Chart ---------- */
.chart-wrap {
position: relative;
margin-top: 18px;
touch-action: none;
}
#chart {
display: block;
width: 100%;
height: 260px;
cursor: crosshair;
border-radius: var(--r-sm);
}
#gridLines line {
stroke: var(--line);
stroke-width: 1;
stroke-dasharray: 3 6;
}
.crosshair line {
stroke: var(--line-2);
stroke-width: 1;
stroke-dasharray: 4 4;
}
#crossDot {
fill: var(--accent-2);
stroke: var(--bg);
stroke-width: 2;
}
#crossDotHalo {
fill: rgba(0, 224, 198, 0.18);
}
.chart-tooltip {
position: absolute;
top: 0;
left: 0;
display: flex;
flex-direction: column;
gap: 1px;
padding: 8px 12px;
background: rgba(27, 30, 39, 0.92);
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
pointer-events: none;
white-space: nowrap;
transform: translate(-50%, 0);
z-index: 5;
}
.tip-value {
font-size: 0.88rem;
font-weight: 700;
}
.tip-date {
font-size: 0.68rem;
color: var(--muted);
}
.chart-labels {
display: flex;
justify-content: space-between;
margin-top: 6px;
font-family: "JetBrains Mono", monospace;
font-size: 0.66rem;
color: var(--muted);
letter-spacing: 0.03em;
}
/* ---------- Allocation ---------- */
.alloc-block {
margin-top: 22px;
padding-top: 18px;
border-top: 1px solid var(--line);
}
.alloc-bar {
display: flex;
height: 8px;
border-radius: var(--r-pill);
overflow: hidden;
gap: 2px;
}
.seg {
display: block;
height: 100%;
border-radius: 2px;
transition: filter 0.18s ease;
}
.alloc-bar:hover .seg {
filter: brightness(1.25);
}
.seg-nova { background: linear-gradient(90deg, #7c5cff, #9d85ff); }
.seg-lum { background: linear-gradient(90deg, #00e0c6, #4ff0dc); }
.seg-stusd { background: #3d8bff; }
.seg-aeth { background: #ffb347; }
.seg-other { background: #5b6072; }
.alloc-legend {
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 8px 18px;
margin: 12px 0 0;
padding: 0;
font-size: 0.78rem;
font-weight: 500;
}
.alloc-legend li {
display: inline-flex;
align-items: center;
gap: 7px;
}
.alloc-legend .mono {
font-size: 0.72rem;
}
.dot {
width: 9px;
height: 9px;
border-radius: 3px;
}
.dot-nova { background: #7c5cff; box-shadow: 0 0 6px rgba(124, 92, 255, 0.7); }
.dot-lum { background: #00e0c6; box-shadow: 0 0 6px rgba(0, 224, 198, 0.6); }
.dot-stusd { background: #3d8bff; }
.dot-aeth { background: #ffb347; }
.dot-other { background: #5b6072; }
/* ---------- Misc ---------- */
.sim-note {
margin: 14px 4px 0;
text-align: center;
font-size: 0.72rem;
color: var(--muted);
opacity: 0.75;
}
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 16px);
background: var(--elevated);
color: var(--text);
border: 1px solid var(--line-2);
border-radius: var(--r-md);
padding: 11px 20px;
font-size: 0.84rem;
font-weight: 500;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 24px rgba(124, 92, 255, 0.15);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease, transform 0.25s ease;
z-index: 50;
}
.toast.is-show {
opacity: 1;
transform: translate(-50%, 0);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
/* ---------- Responsive ---------- */
@media (max-width: 520px) {
body {
padding: 16px 10px;
}
.chart-card {
padding: 18px 16px 18px;
}
.card-head {
flex-direction: column;
}
.btn-ghost {
align-self: flex-start;
}
.range-tabs {
width: 100%;
justify-content: space-between;
}
.range-tab {
padding: 7px 0;
flex: 1;
text-align: center;
}
#chart {
height: 200px;
}
.alloc-legend {
gap: 6px 12px;
font-size: 0.72rem;
}
}/* Web3 — Portfolio Value Chart (PnL) — UI simulation, mock data only. */
(() => {
"use strict";
const W = 720;
const H = 260;
const PAD_TOP = 18;
const PAD_BOTTOM = 14;
const $ = (id) => document.getElementById(id);
const chart = $("chart");
const linePath = $("linePath");
const areaPath = $("areaPath");
const gridLines = $("gridLines");
const crosshair = $("crosshair");
const crossLine = $("crossLine");
const crossDot = $("crossDot");
const crossDotHalo = $("crossDotHalo");
const tooltip = $("tooltip");
const tipValue = $("tipValue");
const tipDate = $("tipDate");
const chartWrap = $("chartWrap");
const chartLabels = $("chartLabels");
const bigValue = $("bigValue");
const pnlRow = $("pnlRow");
const pnlAbs = $("pnlAbs");
const pnlPctText = $("pnlPctText");
const pnlRangeLabel = $("pnlRangeLabel");
const toastEl = $("toast");
const refreshBtn = $("refreshBtn");
/* ---------- helpers ---------- */
const fmtUSD = (n) =>
n.toLocaleString("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
const fmtSigned = (n) => (n >= 0 ? "+" : "−") + fmtUSD(Math.abs(n)).replace("$", "$");
let toastTimer = null;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("is-show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove("is-show"), 2400);
}
// Deterministic PRNG so each range has a stable, realistic-looking series.
function mulberry32(seed) {
return function () {
seed |= 0;
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/* ---------- mock data ---------- */
const NOW = Date.now();
const MIN = 60e3;
const HOUR = 3600e3;
const DAY = 24 * HOUR;
const RANGES = {
"1H": { points: 60, step: MIN, seed: 11, drift: 0.0004, vol: 0.0016, label: "past hour" },
"1D": { points: 96, step: 15 * MIN, seed: 23, drift: 0.0007, vol: 0.004, label: "past 24h" },
"1W": { points: 84, step: 2 * HOUR, seed: 7, drift: 0.0011, vol: 0.009, label: "past week" },
"1M": { points: 90, step: 8 * HOUR, seed: 41, drift: -0.0009, vol: 0.013, label: "past month" },
"1Y": { points: 104, step: 3.5 * DAY, seed: 5, drift: 0.004, vol: 0.03, label: "past year" },
ALL: { points: 120, step: 9 * DAY, seed: 19, drift: 0.0085, vol: 0.045, label: "all time" },
};
const CURRENT_VALUE = 128440.92;
function buildSeries(key) {
const cfg = RANGES[key];
const rnd = mulberry32(cfg.seed);
// Random walk built backwards from the current value so all ranges end at the same number.
const values = new Array(cfg.points);
values[cfg.points - 1] = CURRENT_VALUE;
for (let i = cfg.points - 2; i >= 0; i--) {
const shock = (rnd() - 0.5) * 2 * cfg.vol;
values[i] = values[i + 1] / (1 + cfg.drift + shock);
}
return values.map((v, i) => ({
t: NOW - (cfg.points - 1 - i) * cfg.step,
v,
}));
}
const seriesCache = {};
const getSeries = (key) => (seriesCache[key] ||= buildSeries(key));
/* ---------- chart geometry ---------- */
let current = { key: "1W", data: [], min: 0, max: 1, pts: [] };
function project(data) {
let min = Infinity;
let max = -Infinity;
for (const d of data) {
if (d.v < min) min = d.v;
if (d.v > max) max = d.v;
}
const span = max - min || 1;
const innerH = H - PAD_TOP - PAD_BOTTOM;
const pts = data.map((d, i) => ({
x: (i / (data.length - 1)) * W,
y: PAD_TOP + (1 - (d.v - min) / span) * innerH,
t: d.t,
v: d.v,
}));
return { min, max, pts };
}
// Catmull-Rom → cubic Bézier for a smooth line through every point.
function smoothPath(pts) {
if (pts.length < 2) return "";
let d = `M ${pts[0].x.toFixed(2)} ${pts[0].y.toFixed(2)}`;
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[Math.max(0, i - 1)];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[Math.min(pts.length - 1, i + 2)];
const c1x = p1.x + (p2.x - p0.x) / 6;
const c1y = p1.y + (p2.y - p0.y) / 6;
const c2x = p2.x - (p3.x - p1.x) / 6;
const c2y = p2.y - (p3.y - p1.y) / 6;
d += ` C ${c1x.toFixed(2)} ${c1y.toFixed(2)}, ${c2x.toFixed(2)} ${c2y.toFixed(2)}, ${p2.x.toFixed(2)} ${p2.y.toFixed(2)}`;
}
return d;
}
function drawGrid() {
gridLines.innerHTML = "";
for (let i = 1; i <= 3; i++) {
const y = PAD_TOP + ((H - PAD_TOP - PAD_BOTTOM) / 4) * i;
const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
line.setAttribute("x1", "0");
line.setAttribute("x2", String(W));
line.setAttribute("y1", y.toFixed(1));
line.setAttribute("y2", y.toFixed(1));
gridLines.appendChild(line);
}
}
function fmtTick(ts, key) {
const d = new Date(ts);
if (key === "1H" || key === "1D") {
return d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
}
if (key === "1W" || key === "1M") {
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
return d.toLocaleDateString("en-US", { month: "short", year: "2-digit" });
}
function drawLabels(data, key) {
chartLabels.innerHTML = "";
const idxs = [0, Math.floor(data.length / 3), Math.floor((2 * data.length) / 3), data.length - 1];
for (const i of idxs) {
const span = document.createElement("span");
span.textContent = fmtTick(data[i].t, key);
chartLabels.appendChild(span);
}
}
/* ---------- animated number ---------- */
let valueAnim = null;
function animateValue(from, to) {
cancelAnimationFrame(valueAnim);
const dur = 600;
const start = performance.now();
const ease = (x) => 1 - Math.pow(1 - x, 3);
const tick = (now) => {
const p = Math.min(1, (now - start) / dur);
bigValue.textContent = fmtUSD(from + (to - from) * ease(p));
if (p < 1) valueAnim = requestAnimationFrame(tick);
};
valueAnim = requestAnimationFrame(tick);
}
/* ---------- render range ---------- */
let lastShownValue = CURRENT_VALUE;
function renderRange(key, animate = true) {
const data = getSeries(key);
const { min, max, pts } = project(data);
current = { key, data, min, max, pts };
const d = smoothPath(pts);
linePath.setAttribute("d", d);
areaPath.setAttribute(
"d",
`${d} L ${W} ${H} L 0 ${H} Z`
);
// PnL vs the first point of the range
const open = data[0].v;
const close = data[data.length - 1].v;
const abs = close - open;
const pct = (abs / open) * 100;
const pos = abs >= 0;
pnlRow.classList.toggle("is-pos", pos);
pnlRow.classList.toggle("is-neg", !pos);
pnlAbs.textContent = (pos ? "+" : "−") + fmtUSD(Math.abs(abs));
pnlPctText.textContent = (pos ? "+" : "−") + Math.abs(pct).toFixed(2) + "%";
pnlRangeLabel.textContent = RANGES[key].label;
animateValue(lastShownValue, close);
lastShownValue = close;
drawLabels(data, key);
hideCrosshair();
if (animate) {
const len = linePath.getTotalLength();
linePath.style.transition = "none";
linePath.style.strokeDasharray = `${len}`;
linePath.style.strokeDashoffset = `${len}`;
areaPath.style.transition = "none";
areaPath.style.opacity = "0";
// force reflow, then animate draw-in
linePath.getBoundingClientRect();
linePath.style.transition = "stroke-dashoffset 0.9s cubic-bezier(0.4, 0, 0.2, 1)";
linePath.style.strokeDashoffset = "0";
areaPath.style.transition = "opacity 0.7s ease 0.35s";
areaPath.style.opacity = "1";
linePath.addEventListener(
"transitionend",
() => {
linePath.style.strokeDasharray = "none";
},
{ once: true }
);
}
}
/* ---------- crosshair / tooltip ---------- */
function fmtTooltipDate(ts, key) {
const d = new Date(ts);
if (key === "1H" || key === "1D") {
return d.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
}
return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
}
function showCrosshair(clientX) {
const rect = chart.getBoundingClientRect();
const xRatio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
const pts = current.pts;
const i = Math.round(xRatio * (pts.length - 1));
const p = pts[i];
if (!p) return;
crossLine.setAttribute("x1", p.x.toFixed(2));
crossLine.setAttribute("x2", p.x.toFixed(2));
crossDot.setAttribute("cx", p.x.toFixed(2));
crossDot.setAttribute("cy", p.y.toFixed(2));
crossDotHalo.setAttribute("cx", p.x.toFixed(2));
crossDotHalo.setAttribute("cy", p.y.toFixed(2));
crosshair.style.opacity = "1";
tipValue.textContent = fmtUSD(p.v);
tipDate.textContent = fmtTooltipDate(p.t, current.key);
tooltip.hidden = false;
// position tooltip in CSS pixels, clamped inside the wrap
const pxX = (p.x / W) * rect.width;
const pxY = (p.y / H) * rect.height;
const tw = tooltip.offsetWidth;
const clampedX = Math.min(rect.width - tw / 2 - 4, Math.max(tw / 2 + 4, pxX));
const above = pxY > 70;
tooltip.style.left = `${clampedX}px`;
tooltip.style.top = `${above ? pxY - tooltip.offsetHeight - 18 : pxY + 18}px`;
}
function hideCrosshair() {
crosshair.style.opacity = "0";
tooltip.hidden = true;
}
chartWrap.addEventListener("pointermove", (e) => showCrosshair(e.clientX));
chartWrap.addEventListener("pointerdown", (e) => showCrosshair(e.clientX));
chartWrap.addEventListener("pointerleave", hideCrosshair);
/* ---------- range tabs ---------- */
const tabs = Array.from(document.querySelectorAll(".range-tab"));
tabs.forEach((tab) => {
tab.addEventListener("click", () => {
if (tab.classList.contains("is-active")) return;
tabs.forEach((t) => {
t.classList.toggle("is-active", t === tab);
t.setAttribute("aria-selected", t === tab ? "true" : "false");
});
renderRange(tab.dataset.range);
toast(`Range → ${tab.dataset.range} (mock data)`);
});
});
// arrow-key navigation on the tablist
document.querySelector(".range-tabs").addEventListener("keydown", (e) => {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
e.preventDefault();
const i = tabs.findIndex((t) => t.classList.contains("is-active"));
const next = tabs[(i + (e.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length];
next.focus();
next.click();
});
/* ---------- refresh (simulated) ---------- */
refreshBtn.addEventListener("click", () => {
refreshBtn.classList.add("is-spinning");
refreshBtn.disabled = true;
setTimeout(() => {
refreshBtn.classList.remove("is-spinning");
refreshBtn.disabled = false;
renderRange(current.key);
toast("Portfolio refreshed — simulated snapshot");
}, 900);
});
/* ---------- init ---------- */
drawGrid();
lastShownValue = getSeries("1W")[0].v; // count up from the range open on first paint
renderRange("1W");
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Web3 — Portfolio Value Chart (PnL)</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="page">
<section class="chart-card" aria-label="Portfolio value chart">
<div class="card-glow" aria-hidden="true"></div>
<header class="card-head">
<div class="head-left">
<div class="wallet-row">
<span class="wallet-dot" aria-hidden="true"></span>
<span class="wallet-addr mono" title="Connected wallet (simulated)">0x7a3f…c41d</span>
<span class="chain-pill">Lumen Chain</span>
</div>
<h1 class="card-title">Portfolio value</h1>
</div>
<button class="btn-ghost" id="refreshBtn" type="button" aria-label="Refresh portfolio data">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<path d="M21 12a9 9 0 1 1-2.64-6.36" /><path d="M21 3v6h-6" />
</svg>
Refresh
</button>
</header>
<div class="value-block">
<p class="big-value mono" id="bigValue" aria-live="polite">$128,440.92</p>
<div class="pnl-row" id="pnlRow">
<span class="pnl-abs mono" id="pnlAbs">+$6,214.18</span>
<span class="pnl-pct mono" id="pnlPct">
<svg class="pnl-arrow" width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 4l8 10h-5v6h-6v-6H4z"/></svg>
<span id="pnlPctText">+5.08%</span>
</span>
<span class="pnl-range muted" id="pnlRangeLabel">past week</span>
</div>
</div>
<div class="range-tabs" role="tablist" aria-label="Time range">
<button class="range-tab" role="tab" aria-selected="false" data-range="1H" type="button">1H</button>
<button class="range-tab" role="tab" aria-selected="false" data-range="1D" type="button">1D</button>
<button class="range-tab is-active" role="tab" aria-selected="true" data-range="1W" type="button">1W</button>
<button class="range-tab" role="tab" aria-selected="false" data-range="1M" type="button">1M</button>
<button class="range-tab" role="tab" aria-selected="false" data-range="1Y" type="button">1Y</button>
<button class="range-tab" role="tab" aria-selected="false" data-range="ALL" type="button">ALL</button>
</div>
<div class="chart-wrap" id="chartWrap">
<svg id="chart" viewBox="0 0 720 260" preserveAspectRatio="none" role="img" aria-label="Portfolio value over the selected range">
<defs>
<linearGradient id="areaFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#7c5cff" stop-opacity="0.35" />
<stop offset="55%" stop-color="#7c5cff" stop-opacity="0.10" />
<stop offset="100%" stop-color="#7c5cff" stop-opacity="0" />
</linearGradient>
<linearGradient id="strokeGrad" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#7c5cff" />
<stop offset="100%" stop-color="#00e0c6" />
</linearGradient>
<filter id="glow" x="-20%" y="-60%" width="140%" height="220%">
<feGaussianBlur stdDeviation="5" result="blur" />
<feMerge>
<feMergeNode in="blur" /><feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<g id="gridLines" aria-hidden="true"></g>
<path id="areaPath" fill="url(#areaFill)" d="" />
<path id="linePath" fill="none" stroke="url(#strokeGrad)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" filter="url(#glow)" d="" />
<g id="crosshair" class="crosshair" aria-hidden="true" style="opacity:0">
<line id="crossLine" x1="0" y1="0" x2="0" y2="260" vector-effect="non-scaling-stroke" />
<circle id="crossDotHalo" r="9" />
<circle id="crossDot" r="4" />
</g>
</svg>
<div class="chart-tooltip mono" id="tooltip" role="status" hidden>
<span class="tip-value" id="tipValue">$0.00</span>
<span class="tip-date" id="tipDate">—</span>
</div>
<div class="chart-labels" id="chartLabels" aria-hidden="true"></div>
</div>
<footer class="alloc-block" aria-label="Asset allocation">
<div class="alloc-bar" role="img" aria-label="Allocation: NOVA 42%, LUM 27%, stUSD 18%, AETH 9%, Other 4%">
<span class="seg seg-nova" style="width:42%"></span>
<span class="seg seg-lum" style="width:27%"></span>
<span class="seg seg-stusd" style="width:18%"></span>
<span class="seg seg-aeth" style="width:9%"></span>
<span class="seg seg-other" style="width:4%"></span>
</div>
<ul class="alloc-legend">
<li><span class="dot dot-nova" aria-hidden="true"></span>NOVA <span class="mono muted">42%</span></li>
<li><span class="dot dot-lum" aria-hidden="true"></span>LUM <span class="mono muted">27%</span></li>
<li><span class="dot dot-stusd" aria-hidden="true"></span>stUSD <span class="mono muted">18%</span></li>
<li><span class="dot dot-aeth" aria-hidden="true"></span>AETH <span class="mono muted">9%</span></li>
<li><span class="dot dot-other" aria-hidden="true"></span>Other <span class="mono muted">4%</span></li>
</ul>
</footer>
</section>
<p class="sim-note">UI simulation — fictional tokens on Lumen Chain. No real wallet or RPC calls.</p>
</main>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Portfolio Value Chart (PnL)
The card opens with the connected wallet (0x7a3f…c41d on the fictional Lumen Chain) and a big monospace portfolio total that counts up with an eased animation whenever the data changes. Directly under it, the PnL row shows the absolute move and the percentage for the selected window in a pill that flips between green and red — the arrow rotates to point the right way, and the “past week” / “past 24h” label updates with the range. Every value, address, and tick label is set in JetBrains Mono.
The chart itself is a hand-rolled SVG: each range tab (1H / 1D / 1W / 1M / 1Y / ALL) builds a deterministic random-walk series backwards from the same current value, projects it into the viewBox, and renders a Catmull-Rom-smoothed path with a purple-to-teal gradient stroke, a Gaussian-blur glow filter, and a gradient area fill underneath. Switching ranges replays a stroke-dashoffset draw-in while the fill fades up. Moving the pointer (or touching) over the chart snaps a dashed crosshair and halo dot to the nearest data point and floats a glassy tooltip with the value and formatted date, clamped so it never leaves the card.
The tablist supports arrow-key navigation with visible focus rings, a simulated Refresh button spins and re-draws the current range, and a tiny allocation footer breaks the portfolio into NOVA, LUM, stUSD, AETH, and Other with a segmented bar and glowing legend dots. The layout holds together down to 360px, where the tabs stretch full-width and the chart shortens, and prefers-reduced-motion collapses the animations.
UI-only simulation — no real wallet, RPC, or on-chain calls. Mock data, fictional tokens.