claude-finops 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -2
- package/finops/analytics.py +39 -4
- package/finops/api.py +10 -0
- package/finops/report.py +10 -1
- package/finops/update.py +133 -0
- package/package.json +1 -1
- package/run.py +1 -0
- package/web/app.js +44 -1
- package/web/styles.css +30 -1
package/README.md
CHANGED
|
@@ -290,8 +290,15 @@ place for you to delete once you are happy.
|
|
|
290
290
|
## Privacy
|
|
291
291
|
|
|
292
292
|
`~/.claude-finops/data/finops.db` and the prompt/CSV exports contain **your full prompt text**. The
|
|
293
|
-
server binds to `127.0.0.1` only
|
|
294
|
-
|
|
293
|
+
server binds to `127.0.0.1` only, but treat the database and any export you
|
|
294
|
+
generate as sensitive.
|
|
295
|
+
|
|
296
|
+
The app makes exactly one outbound request of its own: once a day it asks
|
|
297
|
+
`registry.npmjs.org` what the latest `claude-finops` version is, so it can tell
|
|
298
|
+
you when an upgrade is out (npm has no way to push one at you). It sends nothing
|
|
299
|
+
about you or your usage. Turn it off with `NO_UPDATE_NOTIFIER=1` or
|
|
300
|
+
`CLAUDE_FINOPS_NO_UPDATE_CHECK=1`. Provider cost APIs are called only if you
|
|
301
|
+
configure a key with `--set-key`.
|
|
295
302
|
|
|
296
303
|
---
|
|
297
304
|
|
package/finops/analytics.py
CHANGED
|
@@ -30,22 +30,57 @@ def _merge(base, over):
|
|
|
30
30
|
return base
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
# claude_max -> "Max", claude_pro -> "Pro": the tier string carries a 5x/20x
|
|
34
|
+
# suffix we keep, because which Max you are on changes every limit in the app.
|
|
35
|
+
_PLANS = {"claude_max": "Max", "claude_pro": "Pro", "claude_team": "Team",
|
|
36
|
+
"claude_enterprise": "Enterprise"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _plan(acct):
|
|
40
|
+
base = _PLANS.get(acct.get("organizationType") or "")
|
|
41
|
+
mult = ""
|
|
42
|
+
tier = str(acct.get("organizationRateLimitTier") or "")
|
|
43
|
+
if base == "Max":
|
|
44
|
+
for m in ("5x", "20x"):
|
|
45
|
+
if tier.endswith(m):
|
|
46
|
+
mult = " " + m
|
|
47
|
+
return (base + mult) if base else ""
|
|
48
|
+
|
|
49
|
+
|
|
33
50
|
def detect_account():
|
|
34
|
-
"""
|
|
51
|
+
"""Who Claude Code is signed in as, read from ~/.claude.json (actual, not guessed).
|
|
52
|
+
|
|
53
|
+
Everything here is already on this machine, written by Claude Code itself at
|
|
54
|
+
login. We only surface it, so a shared screenshot says whose numbers these
|
|
55
|
+
are — a dashboard with no name on it is the one people misread.
|
|
56
|
+
"""
|
|
35
57
|
try:
|
|
36
58
|
with open(os.path.expanduser("~/.claude.json")) as fh:
|
|
37
59
|
acct = json.load(fh).get("oauthAccount") or {}
|
|
38
60
|
except (OSError, ValueError):
|
|
39
61
|
return {}
|
|
40
|
-
|
|
62
|
+
email = acct.get("emailAddress") or ""
|
|
63
|
+
name = acct.get("fullName") or acct.get("displayName") or ""
|
|
64
|
+
org = acct.get("organizationName") or ""
|
|
65
|
+
out = {"name": name, "email": email,
|
|
66
|
+
# A personal plan names the org after the person; repeating it is noise.
|
|
67
|
+
"org": "" if org == name else org,
|
|
68
|
+
"plan": _plan(acct)}
|
|
69
|
+
if email:
|
|
70
|
+
out["label"] = email
|
|
71
|
+
return {k: v for k, v in out.items() if v}
|
|
41
72
|
|
|
42
73
|
|
|
43
74
|
def load_settings():
|
|
44
75
|
"""Shared defaults (settings.json) + this machine's overrides (settings.local.json)."""
|
|
45
76
|
with open(SETTINGS_PATH) as fh:
|
|
46
77
|
cur = json.load(fh)
|
|
47
|
-
|
|
48
|
-
|
|
78
|
+
# Detected identity first, so a configured settings.json still wins below.
|
|
79
|
+
detected = detect_account()
|
|
80
|
+
acct = cur.setdefault("account", {})
|
|
81
|
+
for k, v in detected.items():
|
|
82
|
+
if not acct.get(k):
|
|
83
|
+
acct[k] = v
|
|
49
84
|
if os.path.exists(LOCAL_SETTINGS_PATH):
|
|
50
85
|
with open(LOCAL_SETTINGS_PATH) as fh:
|
|
51
86
|
_merge(cur, json.load(fh))
|
package/finops/api.py
CHANGED
|
@@ -226,6 +226,9 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
226
226
|
self.send_json({"error": traceback.format_exc()}, 500)
|
|
227
227
|
|
|
228
228
|
def api(self, route, qs):
|
|
229
|
+
if route == "update":
|
|
230
|
+
from .update import check
|
|
231
|
+
return self.send_json(check(force=qs.get("refresh", [""])[0] == "1"))
|
|
229
232
|
if route == "usage":
|
|
230
233
|
from .limits import usage
|
|
231
234
|
return self.send_json(usage(force=qs.get("refresh", [""])[0] == "1"))
|
|
@@ -406,6 +409,11 @@ def _sync_job(log):
|
|
|
406
409
|
return {"built_at": A.q("SELECT value FROM meta WHERE key='built_at'")[0]["value"]}
|
|
407
410
|
|
|
408
411
|
|
|
412
|
+
def _notify_update():
|
|
413
|
+
from .update import notify
|
|
414
|
+
notify()
|
|
415
|
+
|
|
416
|
+
|
|
409
417
|
def serve(port=8787, db=DB_PATH, background=None):
|
|
410
418
|
global A
|
|
411
419
|
if not os.path.exists(db):
|
|
@@ -420,6 +428,8 @@ def serve(port=8787, db=DB_PATH, background=None):
|
|
|
420
428
|
print(f" warehouse: {db}")
|
|
421
429
|
print(f" data : {A.first_day} .. {A.last_day}")
|
|
422
430
|
print(" Ctrl-C to stop.")
|
|
431
|
+
# Off the main thread: a slow registry must never delay the dashboard.
|
|
432
|
+
threading.Thread(target=_notify_update, daemon=True).start()
|
|
423
433
|
try:
|
|
424
434
|
srv.serve_forever()
|
|
425
435
|
except KeyboardInterrupt:
|
package/finops/report.py
CHANGED
|
@@ -3,6 +3,15 @@ import html
|
|
|
3
3
|
from datetime import datetime, timezone
|
|
4
4
|
|
|
5
5
|
|
|
6
|
+
def _who(acct):
|
|
7
|
+
""""Mohit Raj Purohit <mohit@example.com>" when we know both, else whichever
|
|
8
|
+
one we have. A report that leaves the house should name its account."""
|
|
9
|
+
name, email = acct.get("name") or "", acct.get("email") or acct.get("label") or ""
|
|
10
|
+
if name and email:
|
|
11
|
+
return f"{name} <{email}>"
|
|
12
|
+
return name or email or "unknown"
|
|
13
|
+
|
|
14
|
+
|
|
6
15
|
def _f(v, kind="usd"):
|
|
7
16
|
if v is None:
|
|
8
17
|
return "—"
|
|
@@ -52,7 +61,7 @@ td:nth-child(n+2) {{ font-variant-numeric: tabular-nums; }}
|
|
|
52
61
|
ul {{ margin:6px 0 12px 18px; padding:0; }} li {{ margin-bottom:4px; }}
|
|
53
62
|
</style></head><body>
|
|
54
63
|
<h1>Claude AI FinOps Report</h1>
|
|
55
|
-
<div class="sub">Account {html.escape(
|
|
64
|
+
<div class="sub">Account {html.escape(_who(a.settings['account']))} ·
|
|
56
65
|
Billing period {bp['start']} → {bp['end']} ·
|
|
57
66
|
Data {ov['date_range']['first']} – {ov['date_range']['last']} ·
|
|
58
67
|
Generated {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}</div>
|
package/finops/update.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Is there a newer release on npm?
|
|
2
|
+
|
|
3
|
+
npm has no way to push: a global install stays on whatever version it was
|
|
4
|
+
installed at until someone runs `npm i -g claude-finops@latest`. So we ask the
|
|
5
|
+
registry ourselves, once a day, and say so if there is something newer.
|
|
6
|
+
|
|
7
|
+
This is the only outbound request the app makes on its own. It sends nothing
|
|
8
|
+
about you — no identifiers, no usage, not even a User-Agent beyond the package
|
|
9
|
+
name and the version you already published to npm by installing it. Turn it off
|
|
10
|
+
with NO_UPDATE_NOTIFIER=1 (the ecosystem-wide convention) or
|
|
11
|
+
CLAUDE_FINOPS_NO_UPDATE_CHECK=1.
|
|
12
|
+
|
|
13
|
+
The answer is cached in ~/.claude-finops/data/update_cache.json for a day, so a
|
|
14
|
+
dashboard that restarts twenty times makes one request. Every failure is
|
|
15
|
+
silent: no network, a proxy, an offline laptop and a 500 from the registry all
|
|
16
|
+
look the same to the caller, which is "we don't know", never an error.
|
|
17
|
+
"""
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import time
|
|
22
|
+
import urllib.request
|
|
23
|
+
|
|
24
|
+
from .paths import DATA_DIR, ROOT, ensure_dirs
|
|
25
|
+
|
|
26
|
+
CACHE = os.path.join(DATA_DIR, "update_cache.json")
|
|
27
|
+
URL = "https://registry.npmjs.org/claude-finops/latest"
|
|
28
|
+
TTL_S = 24 * 60 * 60
|
|
29
|
+
TIMEOUT_S = 3
|
|
30
|
+
|
|
31
|
+
_NUM = re.compile(r"\d+")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _installed():
|
|
35
|
+
"""Our own version, from the package.json we ship next to the code."""
|
|
36
|
+
try:
|
|
37
|
+
with open(os.path.join(ROOT, "package.json")) as fh:
|
|
38
|
+
return str(json.load(fh).get("version") or "").strip()
|
|
39
|
+
except (OSError, ValueError):
|
|
40
|
+
return ""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _key(v):
|
|
44
|
+
"""Compare 1.10.0 above 1.9.0, and treat 1.0.0-rc.1 as below 1.0.0.
|
|
45
|
+
|
|
46
|
+
Only the numeric core is ordered; a prerelease suffix just loses the tie.
|
|
47
|
+
That is enough for a notifier — we publish plain releases.
|
|
48
|
+
"""
|
|
49
|
+
core, _, pre = str(v).partition("-")
|
|
50
|
+
nums = [int(n) for n in _NUM.findall(core)[:3]]
|
|
51
|
+
return (nums + [0, 0, 0])[:3], 0 if pre else 1
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _newer(latest, current):
|
|
55
|
+
return bool(latest) and bool(current) and _key(latest) > _key(current)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _read_cache():
|
|
59
|
+
try:
|
|
60
|
+
with open(CACHE) as fh:
|
|
61
|
+
c = json.load(fh)
|
|
62
|
+
return c if isinstance(c, dict) else {}
|
|
63
|
+
except (OSError, ValueError):
|
|
64
|
+
return {}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _write_cache(latest):
|
|
68
|
+
ensure_dirs()
|
|
69
|
+
tmp = CACHE + ".tmp"
|
|
70
|
+
try:
|
|
71
|
+
with open(tmp, "w") as fh:
|
|
72
|
+
json.dump({"latest": latest, "checked_at": int(time.time())}, fh)
|
|
73
|
+
os.replace(tmp, CACHE)
|
|
74
|
+
except OSError:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _fetch():
|
|
79
|
+
req = urllib.request.Request(URL, headers={
|
|
80
|
+
"Accept": "application/json",
|
|
81
|
+
"User-Agent": f"claude-finops/{_installed() or '0'}",
|
|
82
|
+
})
|
|
83
|
+
with urllib.request.urlopen(req, timeout=TIMEOUT_S) as r:
|
|
84
|
+
return str(json.load(r).get("version") or "").strip()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def disabled():
|
|
88
|
+
return bool(os.environ.get("NO_UPDATE_NOTIFIER")
|
|
89
|
+
or os.environ.get("CLAUDE_FINOPS_NO_UPDATE_CHECK"))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def check(force=False):
|
|
93
|
+
"""Return {current, latest, update_available, checked_at, ...}.
|
|
94
|
+
|
|
95
|
+
Reads the cache unless it is older than a day (or `force`). Never raises,
|
|
96
|
+
and never blocks longer than TIMEOUT_S.
|
|
97
|
+
"""
|
|
98
|
+
current = _installed()
|
|
99
|
+
out = {"current": current, "latest": "", "update_available": False,
|
|
100
|
+
"command": "npm i -g claude-finops@latest", "checked_at": 0,
|
|
101
|
+
"disabled": disabled()}
|
|
102
|
+
if out["disabled"]:
|
|
103
|
+
return out
|
|
104
|
+
|
|
105
|
+
cache = _read_cache()
|
|
106
|
+
age = time.time() - float(cache.get("checked_at") or 0)
|
|
107
|
+
if cache.get("latest") and age < TTL_S and not force:
|
|
108
|
+
out.update(latest=cache["latest"], checked_at=int(cache["checked_at"]), cached=True)
|
|
109
|
+
else:
|
|
110
|
+
try:
|
|
111
|
+
latest = _fetch()
|
|
112
|
+
except Exception:
|
|
113
|
+
# Offline, blocked, rate-limited — fall back to the last good answer
|
|
114
|
+
# rather than telling anyone anything is wrong.
|
|
115
|
+
latest = str(cache.get("latest") or "")
|
|
116
|
+
out["checked_at"] = int(cache.get("checked_at") or 0)
|
|
117
|
+
out["stale"] = True
|
|
118
|
+
else:
|
|
119
|
+
_write_cache(latest)
|
|
120
|
+
out["checked_at"] = int(time.time())
|
|
121
|
+
out["latest"] = latest
|
|
122
|
+
out["update_available"] = _newer(out["latest"], current)
|
|
123
|
+
return out
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def notify(log=print):
|
|
127
|
+
"""Print one line if a newer version is out. Safe to call from a thread."""
|
|
128
|
+
try:
|
|
129
|
+
u = check()
|
|
130
|
+
except Exception:
|
|
131
|
+
return
|
|
132
|
+
if u.get("update_available"):
|
|
133
|
+
log(f" update : {u['current']} -> {u['latest']} {u['command']}")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-finops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Local FinOps dashboard for Claude Code: what you used, what it cost, why it cost that much, and what to change. Reads your own transcripts, no API key, no data leaves the machine.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"claude-finops": "bin/claude-finops.js"
|
package/run.py
CHANGED
|
@@ -100,6 +100,7 @@ Environment:
|
|
|
100
100
|
CLAUDE_FINOPS_HOME=/path where your data lives (default ~/.claude-finops)
|
|
101
101
|
CLAUDE_PROJECTS=/path where to read transcripts from
|
|
102
102
|
CLAUDE_FINOPS_PYTHON=/path which Python the npm wrapper should use
|
|
103
|
+
NO_UPDATE_NOTIFIER=1 never check npm for a newer release
|
|
103
104
|
"""
|
|
104
105
|
|
|
105
106
|
|
package/web/app.js
CHANGED
|
@@ -151,7 +151,8 @@ function shell() {
|
|
|
151
151
|
<nav class="nav">${NAV.map(([g, items], gi) => `<div class="group g${gi}">${g}</div>` +
|
|
152
152
|
items.map(([id, ic, label]) =>
|
|
153
153
|
`<a data-view="${id}" class="g${gi}${id === 'diagnose' ? ' start' : ''}"><span class="ic">${ic}</span>${label}<span class="nb" data-nb="${id}"></span></a>`).join('')).join('')}
|
|
154
|
-
</nav
|
|
154
|
+
</nav>
|
|
155
|
+
<div class="who" id="who"></div></aside>
|
|
155
156
|
<div class="main">
|
|
156
157
|
<header class="topbar">
|
|
157
158
|
<div class="r1">
|
|
@@ -160,6 +161,7 @@ function shell() {
|
|
|
160
161
|
<span class="spacer"></span>
|
|
161
162
|
<div class="search"><span class="mag">⌕</span>
|
|
162
163
|
<input id="gsearch" placeholder="Search prompts, sessions, models, dates…"></div>
|
|
164
|
+
<button class="iconbtn upd" id="upd" hidden></button>
|
|
163
165
|
<button class="iconbtn" id="tour-btn" title="Walk through this dashboard">? Tour</button>
|
|
164
166
|
<button class="iconbtn" id="theme" title="Toggle theme">◐</button>
|
|
165
167
|
<button class="iconbtn" id="refresh" title="Reload data">↻</button>
|
|
@@ -182,6 +184,7 @@ function shell() {
|
|
|
182
184
|
render();
|
|
183
185
|
};
|
|
184
186
|
$('#refresh').onclick = () => { bust(); render(); };
|
|
187
|
+
updateChip();
|
|
185
188
|
$('#sync').onclick = runSync;
|
|
186
189
|
syncLabel();
|
|
187
190
|
let t;
|
|
@@ -190,6 +193,28 @@ function shell() {
|
|
|
190
193
|
else if (S.view === 'search') go('overview'); }, 260); };
|
|
191
194
|
}
|
|
192
195
|
|
|
196
|
+
/* ---------- update notice ----------
|
|
197
|
+
npm cannot push a new release at anyone, so the server asks the registry once
|
|
198
|
+
a day and we surface the answer here. Silent when you are current, when the
|
|
199
|
+
check is switched off, and when it simply could not reach the registry. */
|
|
200
|
+
async function updateChip() {
|
|
201
|
+
const el = $('#upd');
|
|
202
|
+
if (!el) return;
|
|
203
|
+
let u;
|
|
204
|
+
try { u = await fetch('/api/update').then(r => r.json()); } catch { return; }
|
|
205
|
+
if (!u || !u.update_available) return;
|
|
206
|
+
el.hidden = false;
|
|
207
|
+
el.textContent = `↑ v${u.latest} available`;
|
|
208
|
+
el.title = `You are on ${u.current}. Click to copy: ${u.command}`;
|
|
209
|
+
el.onclick = async () => {
|
|
210
|
+
try {
|
|
211
|
+
await navigator.clipboard.writeText(u.command);
|
|
212
|
+
el.textContent = '✓ command copied';
|
|
213
|
+
setTimeout(() => { el.textContent = `↑ v${u.latest} available`; }, 2200);
|
|
214
|
+
} catch { prompt('Run this to upgrade:', u.command); }
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
193
218
|
/* ---------- filter bar ---------- */
|
|
194
219
|
const RANGES = [['today', 'Today'], ['7d', '7 days'], ['14d', '14 days'], ['30d', '30 days'],
|
|
195
220
|
['period', 'Billing period'], ['all', 'All time'], ['custom', 'Custom']];
|
|
@@ -2199,9 +2224,27 @@ const navAllowed = scope => !scope || (scope === 'claude' ? hasClaude()
|
|
|
2199
2224
|
: scope === 'cloud' ? hasCloudKey() : hasPriced());
|
|
2200
2225
|
// "Claude Code", "Codex", or "agent" for a mixed selection: used in data-availability text
|
|
2201
2226
|
const agentWord = () => { const a = selAgents(); return a.length === 1 ? a[0].name : 'agent'; };
|
|
2227
|
+
// Whose usage this is. Read from ~/.claude.json by the server, so a screenshot
|
|
2228
|
+
// or a shared dashboard always says which account the numbers belong to.
|
|
2229
|
+
function whoBlock() {
|
|
2230
|
+
const el = $('#who'), a = S.opts?.settings?.account || {};
|
|
2231
|
+
if (!el) return;
|
|
2232
|
+
if (!a.name && !a.email) { el.innerHTML = ''; return; }
|
|
2233
|
+
const initials = (a.name || a.email || '?').split(/[\s@.]+/).filter(Boolean)
|
|
2234
|
+
.slice(0, 2).map(w => w[0].toUpperCase()).join('');
|
|
2235
|
+
el.innerHTML = `<div class="av">${esc(initials)}</div>
|
|
2236
|
+
<div class="id">
|
|
2237
|
+
<div class="nm">${esc(a.name || a.email)}</div>
|
|
2238
|
+
<div class="em" title="${esc(a.email || '')}">${esc(a.email || '')}</div>
|
|
2239
|
+
${a.plan || a.org ? `<div class="pl">${esc([a.plan, a.org].filter(Boolean).join(' · '))}</div>` : ''}
|
|
2240
|
+
</div>`;
|
|
2241
|
+
el.title = `Claude Code is signed in as ${a.name || ''} <${a.email || ''}>`.trim();
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2202
2244
|
function applyAgentChrome() {
|
|
2203
2245
|
const a = selAgents();
|
|
2204
2246
|
const label = a.length === 1 ? a[0].name.replace(/ (Code|CLI)$/, '') : a.length ? 'Multi-agent' : 'AI';
|
|
2247
|
+
whoBlock();
|
|
2205
2248
|
const mark = $('.brand .mark');
|
|
2206
2249
|
if (mark) mark.innerHTML = `<span class="dot"></span>${esc(label)} FinOps`;
|
|
2207
2250
|
const sub = $('.brand .sub');
|
package/web/styles.css
CHANGED
|
@@ -77,7 +77,8 @@ button, input, select { font: inherit; color: inherit; }
|
|
|
77
77
|
/* ---------- shell ---------- */
|
|
78
78
|
.app { display: grid; grid-template-columns: 216px 1fr; min-height: 100vh; }
|
|
79
79
|
.sidebar { background: var(--surface); border-right: 1px solid var(--border);
|
|
80
|
-
padding: 14px 0; position: sticky; top:0; height:100vh; overflow-y:auto;
|
|
80
|
+
padding: 14px 0; position: sticky; top:0; height:100vh; overflow-y:auto;
|
|
81
|
+
display:flex; flex-direction:column; }
|
|
81
82
|
.brand { padding: 0 16px 14px; border-bottom: 1px solid var(--border); margin-bottom: 10px; }
|
|
82
83
|
.brand .mark { display:flex; align-items:center; gap:8px; font-weight:660; font-size:14px;
|
|
83
84
|
letter-spacing:-0.01em; }
|
|
@@ -385,3 +386,31 @@ table.tbl .sub { font-size:10.5px; color:var(--muted); }
|
|
|
385
386
|
#tour .tour-ft .spacer { flex: 1; }
|
|
386
387
|
#tour .act.primary { background: var(--accent, #eb6834); border-color: var(--accent, #eb6834); color: #fff; }
|
|
387
388
|
#tour .act[disabled] { opacity: .45; cursor: default; }
|
|
389
|
+
|
|
390
|
+
/* Update notice in the topbar: present, never alarming — this is good news,
|
|
391
|
+
not a problem, so it borrows the accent colour rather than the warning one. */
|
|
392
|
+
.iconbtn.upd {
|
|
393
|
+
border-color: var(--accent, #eb6834);
|
|
394
|
+
color: var(--accent, #eb6834);
|
|
395
|
+
font-weight: 600;
|
|
396
|
+
white-space: nowrap;
|
|
397
|
+
}
|
|
398
|
+
.iconbtn.upd:hover { background: var(--accent, #eb6834); color: #fff; }
|
|
399
|
+
|
|
400
|
+
/* Whose account this dashboard is reading — pinned to the foot of the sidebar,
|
|
401
|
+
so every screenshot carries the name of the account it came from. */
|
|
402
|
+
/* Sticky, because the nav is taller than the viewport and an identity you have
|
|
403
|
+
to scroll to find is one nobody checks. */
|
|
404
|
+
.who { margin-top:auto; position:sticky; bottom:0; z-index:2;
|
|
405
|
+
display:flex; align-items:center; gap:9px;
|
|
406
|
+
padding:12px 16px; background:var(--surface);
|
|
407
|
+
border-top:1px solid var(--border); }
|
|
408
|
+
.who:empty { display:none; }
|
|
409
|
+
.who .av { flex:none; width:26px; height:26px; border-radius:50%; display:grid;
|
|
410
|
+
place-items:center; font-size:10.5px; font-weight:700; color:#fff;
|
|
411
|
+
background: var(--accent, #eb6834); }
|
|
412
|
+
.who .id { min-width:0; }
|
|
413
|
+
.who .nm { font-size:11.5px; font-weight:620; line-height:1.25;
|
|
414
|
+
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
415
|
+
.who .em, .who .pl { font-size:10px; color:var(--muted); line-height:1.35;
|
|
416
|
+
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|