From a2cb7d3700e3b53693b72ea1c4e2a34193d2e0d8 Mon Sep 17 00:00:00 2001 From: Kavi Date: Tue, 2 Jun 2026 03:39:20 -0400 Subject: [PATCH] Import Claude Design dashboard: 12-tab finance terminal Full handoff from claude.ai/design bundle (4ESEfttPcJJzpoqjJxZozQ): - dashboard.html: 12-tab shell (Overview, Spending, Income, People, Cards, Cycles, Balances, Rhythm, Platforms, Banks, Ledger, Quality) - engine.js: data normalisation, categorisation, Sankey graph builder - charts.js: reusable SVG primitives (bars, donuts, heatmaps, multiline) - dashboard.js + dashboard2.js: all tab renderers - styles.css + dashboard.css: Space Grotesk / IBM Plex Mono dark terminal - sankey-view.js + orbit-view.js: Sankey and radial cycle views - money-trace.html: Sankey / Orbit visualisation shell - tweaks-panel.jsx: React palette/glow tweaks panel --- web/charts.js | 234 +++++++++++++ web/dashboard.css | 195 +++++++++++ web/dashboard.html | 785 ++++++++++--------------------------------- web/dashboard.js | 327 ++++++++++++++++++ web/dashboard2.js | 435 ++++++++++++++++++++++++ web/engine.js | 348 +++++++++++++++++++ web/index.html | 191 +++++++++++ web/ledger.json | 1 + web/money-trace.html | 305 +++++++++++++++++ web/orbit-view.js | 156 +++++++++ web/sankey-view.js | 225 +++++++++++++ web/styles.css | 193 +++++++++++ web/tweaks-panel.jsx | 540 +++++++++++++++++++++++++++++ 13 files changed, 3319 insertions(+), 616 deletions(-) create mode 100644 web/charts.js create mode 100644 web/dashboard.css create mode 100644 web/dashboard.js create mode 100644 web/dashboard2.js create mode 100644 web/engine.js create mode 100644 web/index.html create mode 100644 web/ledger.json create mode 100644 web/money-trace.html create mode 100644 web/orbit-view.js create mode 100644 web/sankey-view.js create mode 100644 web/styles.css create mode 100644 web/tweaks-panel.jsx diff --git a/web/charts.js b/web/charts.js new file mode 100644 index 0000000..ad297dd --- /dev/null +++ b/web/charts.js @@ -0,0 +1,234 @@ +/* money-trace · chart primitives (vanilla SVG, dark terminal aesthetic) + All charts share the CSS tokens from styles.css. window.Charts. */ +(function () { + 'use strict'; + const NS = 'http://www.w3.org/2000/svg'; + const el = (n, a, txt) => { const e = document.createElementNS(NS, n); for (const k in (a || {})) e.setAttribute(k, a[k]); if (txt != null) e.textContent = txt; return e; }; + const cssv = v => getComputedStyle(document.documentElement).getPropertyValue(v).trim(); + const fmt = v => window.MT.CLPk(v); + const fmtFull = v => window.MT.CLP(v); + + function clear(c) { while (c.firstChild) c.removeChild(c.firstChild); } + + // ---------- horizontal bar list ---------- + // data: [{label, value, color, sub}], opts:{max, money, onHover} + function hbars(container, data, opts) { + opts = opts || {}; + clear(container); + const max = opts.max || Math.max(...data.map(d => d.value), 1); + const wrap = document.createElement('div'); wrap.className = 'hbars'; + data.forEach(d => { + const row = document.createElement('div'); row.className = 'hbar-row'; + const pct = (d.value / max) * 100; + row.innerHTML = + `
${esc(d.label)}${d.sub ? `${esc(d.sub)}` : ''}
+
+
${opts.money === false ? d.value : fmt(d.value)}
`; + if (opts.onHover) { + row.addEventListener('mousemove', e => opts.onHover(e, d)); + row.addEventListener('mouseleave', () => window.MTtip && window.MTtip.hide()); + } + wrap.appendChild(row); + }); + container.appendChild(wrap); + } + + // ---------- grouped monthly bars (income vs spend) ---------- + // months:[ym], series:[{key,color,values:{ym:amount}}] + function monthlyBars(container, months, series, opts) { + opts = opts || {}; + clear(container); + const W = container.clientWidth || 800, H = opts.height || 240; + const padL = 52, padR = 12, padT = 14, padB = 26; + const innerW = W - padL - padR, innerH = H - padT - padB; + const max = Math.max(1, ...series.flatMap(s => months.map(m => s.values[m] || 0))); + const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, width: '100%', height: H }); + // gridlines + const ticks = 4; + for (let i = 0; i <= ticks; i++) { + const y = padT + innerH * (1 - i / ticks); + svg.appendChild(el('line', { x1: padL, y1: y, x2: W - padR, y2: y, stroke: cssv('--line'), 'stroke-width': 1 })); + svg.appendChild(el('text', { x: padL - 8, y: y + 3, 'text-anchor': 'end', class: 'chart-axis' }, fmt(max * i / ticks))); + } + const groupW = innerW / months.length; + const n = series.length, gap = 3, bw = Math.min(22, (groupW * 0.62 - gap * (n - 1)) / n); + months.forEach((m, mi) => { + const gx = padL + groupW * mi + groupW / 2; + const totalW = bw * n + gap * (n - 1); + series.forEach((s, si) => { + const v = s.values[m] || 0; + const h = (v / max) * innerH; + const x = gx - totalW / 2 + si * (bw + gap); + const y = padT + innerH - h; + const r = el('rect', { x, y, width: bw, height: Math.max(0, h), rx: 2, fill: s.color, opacity: 0.92, class: 'mbar' }); + r.addEventListener('mousemove', e => window.MTtip && window.MTtip.raw(e, s.label + ' · ' + window.MT.MONTH_LABEL(m), fmtFull(v), s.color)); + r.addEventListener('mouseleave', () => window.MTtip && window.MTtip.hide()); + svg.appendChild(r); + }); + svg.appendChild(el('text', { x: gx, y: H - 8, 'text-anchor': 'middle', class: 'chart-axis' }, window.MT.MONTH_LABEL(m).split(" '")[0])); + }); + container.appendChild(svg); + } + + // ---------- line / area (cumulative net) ---------- + function lineChart(container, months, valuesByMonth, opts) { + opts = opts || {}; + clear(container); + const W = container.clientWidth || 800, H = opts.height || 200; + const padL = 56, padR = 14, padT = 14, padB = 26; + const innerW = W - padL - padR, innerH = H - padT - padB; + const vals = months.map(m => valuesByMonth[m] || 0); + let cum = 0; const pts = vals.map((v, i) => { cum += v; return cum; }); + const maxV = Math.max(...pts, 0), minV = Math.min(...pts, 0); + const span = (maxV - minV) || 1; + const x = i => padL + (months.length === 1 ? innerW / 2 : innerW * i / (months.length - 1)); + const y = v => padT + innerH * (1 - (v - minV) / span); + const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, width: '100%', height: H }); + // zero line + if (minV < 0) svg.appendChild(el('line', { x1: padL, y1: y(0), x2: W - padR, y2: y(0), stroke: cssv('--line-2'), 'stroke-dasharray': '3 3' })); + const ticks = 3; + for (let i = 0; i <= ticks; i++) { + const vv = minV + span * i / ticks; + svg.appendChild(el('text', { x: padL - 8, y: y(vv) + 3, 'text-anchor': 'end', class: 'chart-axis' }, fmt(vv))); + } + const col = opts.color || cssv('--c-income'); + let dPath = '', aPath = ''; + pts.forEach((v, i) => { const cmd = i === 0 ? 'M' : 'L'; dPath += `${cmd}${x(i)},${y(v)} `; }); + aPath = dPath + `L${x(pts.length - 1)},${y(minV)} L${x(0)},${y(minV)} Z`; + const gid = 'g' + Math.random().toString(36).slice(2, 7); + const defs = el('defs'); + const lg = el('linearGradient', { id: gid, x1: 0, y1: 0, x2: 0, y2: 1 }); + lg.appendChild(el('stop', { offset: '0%', 'stop-color': col, 'stop-opacity': 0.28 })); + lg.appendChild(el('stop', { offset: '100%', 'stop-color': col, 'stop-opacity': 0 })); + defs.appendChild(lg); svg.appendChild(defs); + svg.appendChild(el('path', { d: aPath, fill: `url(#${gid})` })); + svg.appendChild(el('path', { d: dPath, fill: 'none', stroke: col, 'stroke-width': 2.4, 'stroke-linejoin': 'round' })); + pts.forEach((v, i) => { + const dot = el('circle', { cx: x(i), cy: y(v), r: 3.4, fill: cssv('--bg'), stroke: col, 'stroke-width': 2 }); + dot.addEventListener('mousemove', e => window.MTtip && window.MTtip.raw(e, window.MT.MONTH_LABEL(months[i]), 'running ' + fmtFull(v), col)); + dot.addEventListener('mouseleave', () => window.MTtip && window.MTtip.hide()); + svg.appendChild(dot); + svg.appendChild(el('text', { x: x(i), y: H - 8, 'text-anchor': 'middle', class: 'chart-axis' }, window.MT.MONTH_LABEL(months[i]).split(" '")[0])); + }); + container.appendChild(svg); + } + + // ---------- donut ---------- + // data:[{label,value,color}] + function donut(container, data, opts) { + opts = opts || {}; + clear(container); + const size = opts.size || 200, r = size / 2, ir = r * (opts.inner || 0.62); + const total = data.reduce((a, d) => a + d.value, 0) || 1; + const svg = el('svg', { viewBox: `0 0 ${size} ${size}`, width: size, height: size }); + let a0 = -Math.PI / 2; + data.forEach(d => { + const frac = d.value / total, a1 = a0 + frac * Math.PI * 2; + const large = (a1 - a0) > Math.PI ? 1 : 0; + const p = el('path', { + d: `M${r + Math.cos(a0) * r},${r + Math.sin(a0) * r} A${r},${r} 0 ${large} 1 ${r + Math.cos(a1) * r},${r + Math.sin(a1) * r} L${r + Math.cos(a1) * ir},${r + Math.sin(a1) * ir} A${ir},${ir} 0 ${large} 0 ${r + Math.cos(a0) * ir},${r + Math.sin(a0) * ir} Z`, + fill: d.color, opacity: 0.92, class: 'donut-seg' + }); + p.addEventListener('mousemove', e => window.MTtip && window.MTtip.raw(e, d.label, fmtFull(d.value) + ' · ' + Math.round(frac * 100) + '%', d.color)); + p.addEventListener('mouseleave', () => window.MTtip && window.MTtip.hide()); + svg.appendChild(p); + a0 = a1; + }); + if (opts.centerTop || opts.centerBot) { + svg.appendChild(el('text', { x: r, y: r - 2, 'text-anchor': 'middle', class: 'donut-center-top' }, opts.centerTop || '')); + svg.appendChild(el('text', { x: r, y: r + 16, 'text-anchor': 'middle', class: 'donut-center-bot' }, opts.centerBot || '')); + } + container.appendChild(svg); + } + + const esc = s => String(s == null ? '' : s).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c])); + + // ---------- multi-line (balance trajectories over a date domain) ---------- + // series:[{label,color,points:[{t:Date|ms, v}]}], opts:{height, money} + function multiLine(container, series, opts) { + opts = opts || {}; + clear(container); + const W = container.clientWidth || 800, H = opts.height || 280; + const padL = 60, padR = 16, padT = 16, padB = 28; + const innerW = W - padL - padR, innerH = H - padT - padB; + const all = series.flatMap(s => s.points); + if (!all.length) { container.innerHTML = '
No balance data
'; return; } + const ts = all.map(p => +p.t); + const t0 = Math.min(...ts), t1 = Math.max(...ts); + let vMax = Math.max(...all.map(p => p.v)), vMin = Math.min(...all.map(p => p.v), 0); + const vspan = (vMax - vMin) || 1; + const x = t => padL + (t1 === t0 ? innerW / 2 : innerW * (+t - t0) / (t1 - t0)); + const y = v => padT + innerH * (1 - (v - vMin) / vspan); + const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, width: '100%', height: H }); + // gridlines + y labels + const ticks = 4; + for (let i = 0; i <= ticks; i++) { + const vv = vMin + vspan * i / ticks, yy = y(vv); + svg.appendChild(el('line', { x1: padL, y1: yy, x2: W - padR, y2: yy, stroke: cssv('--line'), 'stroke-width': 1 })); + svg.appendChild(el('text', { x: padL - 8, y: yy + 3, 'text-anchor': 'end', class: 'chart-axis' }, fmt(vv))); + } + if (vMin < 0) svg.appendChild(el('line', { x1: padL, y1: y(0), x2: W - padR, y2: y(0), stroke: cssv('--c-spend'), 'stroke-opacity': 0.4, 'stroke-dasharray': '4 3' })); + // x labels (month ticks) + const months = []; + let cur = new Date(t0); cur.setDate(1); + while (+cur <= t1) { months.push(new Date(cur)); cur.setMonth(cur.getMonth() + 1); } + const xstep = Math.ceil(months.length / 8); + months.forEach((m, i) => { if (i % xstep === 0) svg.appendChild(el('text', { x: x(+m), y: H - 9, 'text-anchor': 'middle', class: 'chart-axis' }, window.MT.MONTH_LABEL(m.toISOString().slice(0, 7)).split(" '")[0])); }); + // lines + series.forEach(s => { + const pts = s.points.slice().sort((a, b) => +a.t - +b.t); + if (!pts.length) return; + let dPath = ''; + pts.forEach((p, i) => { dPath += `${i === 0 ? 'M' : 'L'}${x(+p.t)},${y(p.v)} `; }); + svg.appendChild(el('path', { d: dPath, fill: 'none', stroke: s.color, 'stroke-width': 2, 'stroke-linejoin': 'round', 'stroke-linecap': 'round', opacity: 0.9 })); + // endpoint dot + const last = pts[pts.length - 1]; + svg.appendChild(el('circle', { cx: x(+last.t), cy: y(last.v), r: 3, fill: s.color })); + // hover hit-dots + pts.forEach(p => { + const dot = el('circle', { cx: x(+p.t), cy: y(p.v), r: 7, fill: 'transparent', style: 'cursor:pointer' }); + dot.addEventListener('mousemove', e => window.MTtip && window.MTtip.raw(e, s.label, new Date(+p.t).toISOString().slice(0, 10) + ' · ' + fmtFull(p.v), s.color)); + dot.addEventListener('mouseleave', () => window.MTtip && window.MTtip.hide()); + svg.appendChild(dot); + }); + }); + container.appendChild(svg); + } + + // ---------- heatmap grid ---------- + // rows:[{label, cells:[{v, raw, title}]}], colLabels:[], opts:{color, max} + function heatmap(container, rows, colLabels, opts) { + opts = opts || {}; + clear(container); + const max = opts.max || Math.max(1, ...rows.flatMap(r => r.cells.map(c => c.v))); + const base = opts.color || cssv('--c-income'); + const wrap = document.createElement('div'); wrap.className = 'heatmap'; + // header + const head = document.createElement('div'); head.className = 'hm-row hm-head'; + head.innerHTML = `
` + colLabels.map(c => `
${esc(c)}
`).join(''); + wrap.appendChild(head); + rows.forEach(r => { + const row = document.createElement('div'); row.className = 'hm-row'; + let html = `
${esc(r.label)}
`; + r.cells.forEach(c => { + const alpha = c.v <= 0 ? 0 : 0.12 + 0.88 * Math.pow(c.v / max, 0.6); + html += `
`; + }); + row.innerHTML = html; + wrap.appendChild(row); + }); + container.appendChild(wrap); + if (opts.onHover) wrap.querySelectorAll('.hm-cell').forEach((cell, i) => { }); + // attach hover via cell data + let idx = 0; + rows.forEach(r => r.cells.forEach(c => { + const cell = wrap.querySelectorAll('.hm-cell')[idx++]; + if (c.raw && c.raw.length && window.MTtip) { + cell.addEventListener('mousemove', e => window.MTtip.rows(e, c.title, base, c.raw)); + cell.addEventListener('mouseleave', () => window.MTtip.hide()); + } + })); + } + + window.Charts = { hbars, monthlyBars, lineChart, donut, multiLine, heatmap, clear, esc }; +})(); diff --git a/web/dashboard.css b/web/dashboard.css new file mode 100644 index 0000000..50f4f6e --- /dev/null +++ b/web/dashboard.css @@ -0,0 +1,195 @@ +/* money-trace · dashboard layout & charts */ +.dash-body { overflow-y: auto; overflow-x: hidden; } + +/* tab nav */ +.tabs { + display: flex; gap: 2px; padding: 0 22px; border-bottom: 1px solid var(--line); + background: var(--panel); position: sticky; top: 0; z-index: 20; + overflow-x: auto; scrollbar-width: thin; +} +.tabs::-webkit-scrollbar { height: 4px; } +.tabs::-webkit-scrollbar-thumb { background: var(--line-2); border-radius: 2px; } +.tab { + font-family: var(--font-ui); font-size: 13.5px; color: var(--ink-mute); + background: transparent; border: 0; border-bottom: 2px solid transparent; + padding: 15px 18px 13px; cursor: pointer; display: flex; align-items: center; gap: 9px; + transition: .16s; white-space: nowrap; +} +.tab:hover { color: var(--ink); } +.tab.on { color: var(--ink); border-bottom-color: var(--c-income); } +.tab svg { width: 16px; height: 16px; opacity: .85; } +.tab .tnum { font-family: var(--font-num); font-size: 10px; color: var(--ink-dim); } + +/* panels */ +.panel-wrap { padding: 22px; max-width: 1320px; margin: 0 auto; } +.panel { display: none; animation: fade .22s ease; } +.panel.on { display: block; } +@keyframes fade { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } + +.panel-head { margin-bottom: 18px; } +.panel-head h2 { font-size: 22px; font-weight: 600; margin: 0 0 5px; letter-spacing: -0.01em; } +.panel-head p { font-size: 13.5px; color: var(--ink-mute); margin: 0; max-width: 640px; line-height: 1.5; } +.panel-head p b { color: var(--ink); font-weight: 500; } + +/* grid */ +.grid { display: grid; gap: 16px; } +.g-2 { grid-template-columns: 1fr 1fr; } +.g-3 { grid-template-columns: repeat(3, 1fr); } +.g-1-2 { grid-template-columns: 320px 1fr; } +.g-2-1 { grid-template-columns: 1fr 340px; } +@media (max-width: 1080px) { .g-2, .g-3, .g-1-2, .g-2-1 { grid-template-columns: 1fr; } } + +/* card */ +.card { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 18px; } +.card.tall { min-height: 280px; } +.card h3 { font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-mute); margin: 0 0 16px; font-weight: 600; display: flex; align-items: center; gap: 8px; } +.card h3 .pill { margin-left: auto; font-family: var(--font-num); font-size: 11px; color: var(--ink-dim); text-transform: none; letter-spacing: 0; } +.card .chart-host { width: 100%; } + +/* KPI cards */ +.kpi { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 18px 20px; position: relative; overflow: hidden; } +.kpi::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--accent, var(--ink-mute)); } +.kpi .k-lab { font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink-mute); margin-bottom: 10px; } +.kpi .k-val { font-size: 30px; font-weight: 500; line-height: 1; font-family: var(--font-num); letter-spacing: -0.01em; } +.kpi .k-val small { font-size: 15px; color: var(--ink-dim); } +.kpi .k-sub { font-size: 11.5px; color: var(--ink-dim); margin-top: 8px; } +.kpi .k-sub b { font-family: var(--font-num); color: var(--ink-mute); font-weight: 500; } + +/* hbars */ +.hbars { display: flex; flex-direction: column; gap: 11px; } +.hbar-row { display: grid; grid-template-columns: 168px 1fr 96px; align-items: center; gap: 14px; cursor: default; } +.hbar-label { font-size: 13px; color: var(--ink); display: flex; flex-direction: column; gap: 2px; overflow: hidden; } +.hbar-label .hbar-sub { font-size: 10.5px; color: var(--ink-dim); font-family: var(--font-num); } +.hbar-track { height: 22px; background: var(--bg); border-radius: 5px; overflow: hidden; } +.hbar-fill { height: 100%; border-radius: 5px; opacity: 0.9; transition: width .5s cubic-bezier(.3,1,.4,1); } +.hbar-val { text-align: right; font-size: 13px; color: var(--ink); } +.hbar-row:hover .hbar-fill { opacity: 1; } + +/* legend chips */ +.chips { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-top: 14px; } +.chip { display: flex; align-items: center; gap: 7px; font-size: 12px; color: var(--ink-mute); } +.chip i { width: 10px; height: 10px; border-radius: 3px; } +.chip b { color: var(--ink); font-family: var(--font-num); font-weight: 500; } + +/* chart axis text */ +.chart-axis { fill: var(--ink-dim); font-size: 10px; font-family: var(--font-num); } +.donut-center-top { fill: var(--ink); font-size: 20px; font-family: var(--font-num); font-weight: 500; } +.donut-center-bot { fill: var(--ink-dim); font-size: 10.5px; letter-spacing: 0.08em; text-transform: uppercase; font-family: var(--font-ui); } +.donut-wrap { display: flex; align-items: center; gap: 22px; } + +/* big callout */ +.callout { display: flex; align-items: baseline; gap: 14px; padding: 4px 0; } +.callout .big { font-size: 54px; font-weight: 600; font-family: var(--font-num); line-height: 1; letter-spacing: -0.02em; } +.callout .big.mult { color: var(--c-fee); } +.callout .ctxt { font-size: 13px; color: var(--ink-mute); line-height: 1.5; } +.callout .ctxt b { color: var(--ink); } + +/* bank table */ +.btable { width: 100%; border-collapse: collapse; font-size: 13px; } +.btable th { text-align: right; font-size: 10.5px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-dim); font-weight: 600; padding: 0 14px 12px; border-bottom: 1px solid var(--line); } +.btable th:first-child { text-align: left; } +.btable td { padding: 13px 14px; border-bottom: 1px solid var(--line); text-align: right; font-family: var(--font-num); color: var(--ink); } +.btable td:first-child { text-align: left; font-family: var(--font-ui); } +.btable tr:hover td { background: rgba(255,255,255,0.02); } +.btable .bk-name { display: flex; align-items: center; gap: 10px; } +.btable .bk-dot { width: 9px; height: 9px; border-radius: 3px; flex: none; } +.btable .bk-meta { font-size: 10.5px; color: var(--ink-dim); font-family: var(--font-num); } +.btable .minibar { display: inline-flex; height: 7px; border-radius: 3px; overflow: hidden; width: 120px; background: var(--bg); vertical-align: middle; } +.btable .minibar i { height: 100%; display: block; } +.btable td.muted { color: var(--ink-dim); } + +/* insight strip */ +.insight { display: flex; gap: 12px; align-items: flex-start; background: var(--panel-2); border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; font-size: 13px; color: var(--ink-mute); line-height: 1.55; } +.insight .i-ic { width: 26px; height: 26px; border-radius: 7px; display: grid; place-items: center; flex: none; background: var(--bg); color: var(--c-income); } +.insight b { color: var(--ink); font-weight: 500; } +.insight .num { color: var(--ink); } + +/* month mini selector for panels */ +.mini-months { display: flex; gap: 3px; align-items: center; } +.mini-months .mm { font-family: var(--font-num); font-size: 11px; color: var(--ink-dim); padding: 4px 8px; border-radius: 6px; cursor: pointer; } +.mini-months .mm:hover { color: var(--ink); background: var(--bg); } +.mini-months .mm.on { color: var(--bg); background: var(--c-income); } + +/* top merchants list */ +.mlist { display: flex; flex-direction: column; } +.mrow { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line); } +.mrow:last-child { border-bottom: 0; } +.mrow .m-rank { font-family: var(--font-num); font-size: 11px; color: var(--ink-dim); width: 20px; } +.mrow .m-name { flex: 1; font-size: 13px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.mrow .m-cat { font-size: 10.5px; color: var(--ink-dim); margin-left: 6px; } +.mrow .m-amt { font-family: var(--font-num); font-size: 13px; color: var(--ink); } +.mrow .m-cnt { font-family: var(--font-num); font-size: 10.5px; color: var(--ink-dim); width: 48px; text-align: right; } + +/* ---- heatmap ---- */ +.heatmap { display: flex; flex-direction: column; gap: 4px; } +.hm-row { display: grid; grid-template-columns: 64px repeat(var(--cols, 31), 1fr); gap: 4px; align-items: center; } +.hm-head { margin-bottom: 2px; } +.hm-rowlab { font-size: 11.5px; color: var(--ink-mute); text-align: right; padding-right: 8px; font-family: var(--font-ui); } +.hm-collab { font-size: 9px; color: var(--ink-dim); text-align: center; font-family: var(--font-num); } +.hm-cell { aspect-ratio: 1; border-radius: 3px; background: color-mix(in oklab, var(--hc) calc(var(--a) * 100%), var(--bg)); border: 1px solid var(--line); transition: transform .1s; min-height: 14px; } +.hm-cell[data-v="0"] { background: var(--bg); } +.hm-cell:hover { transform: scale(1.18); border-color: var(--line-2); cursor: default; } +.hm-scale { display: flex; align-items: center; gap: 8px; font-size: 10.5px; color: var(--ink-dim); margin-top: 12px; justify-content: flex-end; } +.hm-scale .sw { display: flex; gap: 3px; } +.hm-scale .sw i { width: 14px; height: 14px; border-radius: 3px; } + +/* ---- account picker chips ---- */ +.acct-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; } +.acct-chip { display: flex; align-items: center; gap: 8px; padding: 7px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--panel); cursor: pointer; font-size: 12.5px; color: var(--ink-mute); transition: .14s; } +.acct-chip:hover { border-color: var(--line-2); color: var(--ink); } +.acct-chip.on { color: var(--ink); background: var(--panel-2); } +.acct-chip i { width: 9px; height: 9px; border-radius: 3px; } +.acct-chip .ac-end { font-family: var(--font-num); font-size: 11px; color: var(--ink-dim); } + +/* ---- ledger explorer table ---- */ +.lx-controls { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; margin-bottom: 16px; } +.lx-search { flex: 1; min-width: 220px; position: relative; } +.lx-search input { width: 100%; background: var(--bg); border: 1px solid var(--line); border-radius: 9px; padding: 11px 14px 11px 38px; color: var(--ink); font-family: var(--font-ui); font-size: 13.5px; } +.lx-search input:focus { outline: none; border-color: var(--line-2); } +.lx-search svg { position: absolute; left: 12px; top: 11px; width: 16px; height: 16px; color: var(--ink-dim); } +.lx-select { background: var(--bg); border: 1px solid var(--line); border-radius: 9px; padding: 11px 13px; color: var(--ink); font-family: var(--font-ui); font-size: 13px; cursor: pointer; } +.lx-count { font-size: 12px; color: var(--ink-dim); font-family: var(--font-num); white-space: nowrap; } +.lx-table-wrap { border: 1px solid var(--line); border-radius: 12px; overflow: hidden; } +.lx-table { width: 100%; border-collapse: collapse; font-size: 12.5px; } +.lx-table thead { position: sticky; top: 0; background: var(--panel-2); z-index: 2; } +.lx-table th { text-align: left; font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-dim); font-weight: 600; padding: 11px 14px; border-bottom: 1px solid var(--line); cursor: pointer; user-select: none; white-space: nowrap; } +.lx-table th:hover { color: var(--ink-mute); } +.lx-table th.r, .lx-table td.r { text-align: right; } +.lx-table td { padding: 10px 14px; border-bottom: 1px solid var(--line); color: var(--ink-mute); } +.lx-table tr:last-child td { border-bottom: 0; } +.lx-table tr:hover td { background: rgba(255,255,255,0.02); } +.lx-table .lx-date { font-family: var(--font-num); font-size: 11.5px; color: var(--ink-dim); white-space: nowrap; } +.lx-table .lx-desc { color: var(--ink); max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.lx-table .lx-amt { font-family: var(--font-num); white-space: nowrap; } +.lx-table .lx-amt.cr { color: var(--c-income); } +.lx-table .lx-amt.db { color: var(--ink); } +.lx-flow-tag { display: inline-flex; align-items: center; gap: 5px; font-size: 10.5px; padding: 2px 8px; border-radius: 20px; border: 1px solid var(--line-2); color: var(--ink-mute); white-space: nowrap; } +.lx-flow-tag i { width: 6px; height: 6px; border-radius: 50%; } +.lx-pdf { color: var(--ink-dim); font-family: var(--font-num); font-size: 10.5px; } +.lx-more { text-align: center; padding: 14px; color: var(--ink-dim); font-size: 12px; cursor: pointer; } +.lx-more:hover { color: var(--ink); background: rgba(255,255,255,0.02); } + +/* ---- coverage grid (data quality) ---- */ +.cov-grid { overflow-x: auto; } +.cov-row { display: grid; grid-template-columns: 190px repeat(var(--cols, 15), 1fr); gap: 3px; align-items: center; margin-bottom: 3px; } +.cov-head .cov-cell { background: transparent; } +.cov-rowlab { font-size: 11.5px; color: var(--ink); display: flex; align-items: center; gap: 8px; overflow: hidden; } +.cov-rowlab .cov-dot { width: 8px; height: 8px; border-radius: 3px; flex: none; } +.cov-rowlab .cov-meta { font-size: 9.5px; color: var(--ink-dim); font-family: var(--font-num); } +.cov-collab { font-size: 9px; color: var(--ink-dim); text-align: center; font-family: var(--font-num); writing-mode: vertical-rl; height: 36px; } +.cov-cell { height: 22px; border-radius: 3px; background: var(--bg); border: 1px solid var(--line); position: relative; } +.cov-cell.has { background: color-mix(in oklab, var(--c-income) 30%, var(--bg)); border-color: color-mix(in oklab, var(--c-income) 45%, var(--bg)); } +.cov-cell.has:hover { background: color-mix(in oklab, var(--c-income) 55%, var(--bg)); } + +/* ---- mini stat row ---- */ +.qstats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 16px; } +.qstat { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 15px 16px; } +.qstat .qv { font-family: var(--font-num); font-size: 24px; color: var(--ink); } +.qstat .ql { font-size: 11px; color: var(--ink-mute); margin-top: 5px; } +.qbar { height: 8px; border-radius: 4px; background: var(--bg); overflow: hidden; margin-top: 10px; display: flex; } +.qbar i { height: 100%; } +@media (max-width: 1080px) { .qstats { grid-template-columns: 1fr 1fr; } } + +/* net pill */ +.net-pos { color: var(--c-income); } +.net-neg { color: var(--c-spend); } diff --git a/web/dashboard.html b/web/dashboard.html index d44a9b2..8ae965e 100644 --- a/web/dashboard.html +++ b/web/dashboard.html @@ -1,637 +1,190 @@ - - + + - - -money-trace · dashboard - + + +Money Trace · Dashboard + + + + + +
reconstructing money flow…
- - - - -
- -
-

money‑trace · cargando…

-
-
- conectando +