use crate::usage_signal::{AccountInfo, AgentUsage, DailyInstance, NamedBucket, SessionRecord, TimeBucket, ToolSummary, UsageSnapshot};
pub fn render(snap: &UsageSnapshot) -> String {
let lang = Language::detect();
let updated = snap.collected_at.as_deref().unwrap_or("-");
let mut html = String::new();
html.push_str(HEAD);
html.push_str(r#"<div class="app-shell">"#);
html.push_str(&format!(
r#"<header><div class="title-block"><h1>{}</h1><div class="muted">{} {}</div></div></header>"#,
lang.text("Agent Usage Detail", "Ajan Kullanım Detayı"),
lang.text("Updated", "Güncellendi"),
html_escape(updated),
));
html.push_str(
&format!(r#"<nav class="tabs"><div class="seg-ctrl">
<button class="tab-btn active" data-tab="today">{}</button>
<button class="tab-btn" data-tab="cost">{}</button>
<button class="tab-btn" data-tab="history">{}</button>
<button class="tab-btn" data-tab="sessions">{}</button>
<button class="tab-btn" data-tab="breakdown">{}</button>
</div></nav>"#,
lang.text("Today", "Bugün"),
lang.text("Cost", "Maliyet"),
lang.text("History", "Geçmiş"),
lang.text("Sessions", "Oturumlar"),
lang.text("Breakdown", "Kırılım"),
),
);
html.push_str(r#"<main>"#);
html.push_str(&panel("today", true, &render_today(snap, lang)));
html.push_str(&panel("cost", false, &render_cost(snap, lang)));
html.push_str(&panel("history", false, &render_history(snap, lang)));
html.push_str(&panel("sessions", false, &render_sessions(snap, lang)));
html.push_str(&panel("breakdown", false, &render_breakdown(snap, lang)));
html.push_str(r#"</main>"#);
html.push_str(r#"</div>"#);
html.push_str(FOOT);
html
}
use crate::i18n::Language;
fn panel(id: &str, active: bool, body: &str) -> String {
let class = if active {
"tab-panel active"
} else {
"tab-panel"
};
format!(r#"<div class="{class}" id="tab-{id}">{body}</div>"#)
}
fn render_today(snap: &UsageSnapshot, lang: Language) -> String {
let mut out = format!(
r#"<section class="today-grid">{}{}</section>"#,
today_agent("Claude", &snap.claude, lang),
today_agent("Codex", &snap.codex, lang)
);
if !snap.accounts.is_empty() {
out.push_str(&render_accounts(&snap.accounts, &snap.claude, lang));
}
if !snap.others.is_empty() {
out.push_str(&render_other_tools(&snap.others, lang));
}
out
}
fn render_cost(snap: &UsageSnapshot, lang: Language) -> String {
let note = format!(
r#"<div class="cost-note">{}</div>"#,
lang.text(
"Estimated API-equivalent cost. You're on a subscription, so these are not billed amounts — they show what the metered API would charge, priced from the LiteLLM rate table (same source as ccusage).",
"Tahmini API-eşdeğeri maliyet. Abonelik kullandığınız için bunlar faturalandırılan tutarlar değildir — ölçümlü API'nin ne kadar ücretlendireceğini gösterir; oranlar LiteLLM tablosundan (ccusage ile aynı kaynak) alınır.",
),
);
format!(
r#"<div class="stack">{note}{}{}</div>"#,
cost_agent("Claude", &snap.claude, lang),
cost_agent("Codex", &snap.codex, lang),
)
}
fn cost_agent(name: &str, usage: &AgentUsage, lang: Language) -> String {
let mut tiles_inner = format!(
"{}{}{}{}",
metric(lang.text("Today", "Bugün"), &format_usd(usage.cost_today)),
metric(lang.text("Last 7 days", "Son 7 gün"), &format_usd(usage.cost_7d)),
metric(lang.text("Last 30 days", "Son 30 gün"), &format_usd(usage.total_cost_30d)),
metric(
lang.text("30d in / out", "30g girdi / çıktı"),
&format!("{} / {}", format_tokens(usage.total_input_30d), format_tokens(usage.total_output_30d)),
),
);
if usage.cache_savings_30d > 0.0 {
tiles_inner.push_str(&metric(
lang.text("30d cache saved", "30g önbellek tasarrufu"),
&format_usd(usage.cache_savings_30d),
));
}
let tiles = format!(r#"<div class="metric-grid cost-tiles">{tiles_inner}</div>"#);
format!(
r#"<section class="agent-section"><h2>{}</h2>{tiles}{}</section>"#,
html_escape(name),
daily_instances_table(&usage.by_day_project, lang),
)
}
fn daily_instances_table(items: &[DailyInstance], lang: Language) -> String {
let mut rows = String::new();
let mut last_date = "";
for it in items.iter().take(120) {
let date_cell = if it.date == last_date {
String::new()
} else {
html_escape(&it.date)
};
last_date = &it.date;
let models = it
.models
.iter()
.map(|m| html_escape(&short_model(m)))
.collect::<Vec<_>>()
.join(", ");
rows.push_str(&format!(
r#"<tr><td class="date">{date_cell}</td><td><strong>{}</strong></td><td class="muted">{}</td><td class="num">{}</td><td class="num">{}</td><td class="num">{}</td><td class="num">{}</td><td class="num">{}</td><td class="num cost">{}</td></tr>"#,
html_escape(&it.project),
models,
format_tokens(it.input),
format_tokens(it.output),
format_tokens(it.cache_creation),
format_tokens(it.cache_read),
format_tokens(it.tokens),
format_usd(it.cost),
));
}
if rows.is_empty() {
rows = format!(
r#"<tr><td colspan="9" class="empty">{}</td></tr>"#,
lang.text("no usage in the last 30 days", "son 30 günde kullanım yok")
);
}
format!(
r#"<div class="table-card wide" style="margin-top:14px"><table><thead><tr><th>{}</th><th>{}</th><th>{}</th><th>{}</th><th>{}</th><th>{}</th><th>{}</th><th>{}</th><th>{}</th></tr></thead><tbody>{rows}</tbody></table></div>"#,
lang.text("date", "tarih"),
lang.text("project", "proje"),
lang.text("models", "modeller"),
lang.text("input", "girdi"),
lang.text("output", "çıktı"),
lang.text("cache +", "önbellek +"),
lang.text("cache read", "önbellek oku"),
lang.text("tokens", "token"),
lang.text("cost", "maliyet"),
)
}
fn plan_label(a: &AccountInfo) -> &'static str {
match a.subscription_type.as_str() {
"pro" => "Pro",
"max" => {
if a.rate_limit_tier.contains("20x") { "Max 20×" }
else if a.rate_limit_tier.contains("5x") { "Max 5×" }
else { "Max" }
}
"free" => "Free",
_ => "Unknown",
}
}
fn render_accounts(accounts: &[AccountInfo], claude: &AgentUsage, lang: Language) -> String {
let active = accounts.iter().find(|a| a.is_active).or_else(|| accounts.first());
let limit_bars = if let Some(a) = active {
let plan_name = format!(
"{} — <span class=\"plan-badge plan-{}\">{}</span>",
html_escape(&a.name),
html_escape(&a.subscription_type),
plan_label(a)
);
let bar5h = render_limit_bar(
lang.text("Session (5h)", "Oturum (5s)"),
claude.session_5h_percent,
a.limit_5h_messages,
lang,
);
let bar7d = render_limit_bar(
lang.text("Week (7d)", "Hafta (7g)"),
claude.week_7d_percent,
a.limit_7d_messages,
lang,
);
format!(
r#"<div class="limit-account-header">{plan_name}</div><div class="limit-bars">{bar5h}{bar7d}</div>"#
)
} else {
String::new()
};
let other_rows: String = accounts.iter()
.filter(|a| !a.is_active)
.map(|a| {
let l5h = if a.limit_5h_messages > 0 { format!("{} msgs", a.limit_5h_messages) } else { "—".to_string() };
let l7d = if a.limit_7d_messages > 0 { format!("{} msgs", a.limit_7d_messages) } else { "—".to_string() };
format!(
r#"<tr><td><strong>{}</strong></td><td><span class="plan-badge plan-{}">{}</span></td><td class="num">{}</td><td class="num">{}</td></tr>"#,
html_escape(&a.name),
html_escape(&a.subscription_type),
plan_label(a),
l5h,
l7d,
)
})
.collect();
let other_table = if !other_rows.is_empty() {
format!(
r#"<div class="table-card wide" style="margin-top:10px"><table><thead><tr><th>{}</th><th>{}</th><th>{}</th><th>{}</th></tr></thead><tbody>{other_rows}</tbody></table></div>"#,
lang.text("account", "hesap"),
lang.text("plan", "plan"),
lang.text("5h limit", "5s limit"),
lang.text("7d limit", "7g limit"),
)
} else {
String::new()
};
format!(
r#"<section class="accounts-section"><h2>{}</h2>{limit_bars}{other_table}</section>"#,
lang.text("Limits", "Limitler"),
)
}
fn render_limit_bar(label: &str, pct: Option<f64>, total_msgs: u32, lang: Language) -> String {
let (pct_val, pct_text, used_text) = if let Some(p) = pct {
let used = ((p / 100.0) * total_msgs as f64).round() as u32;
let used_label = if lang == Language::Tr {
format!("{} / {} mesaj", used, total_msgs)
} else {
format!("{} / {} msgs", used, total_msgs)
};
(p, format!("{p:.0}%"), used_label)
} else if total_msgs > 0 {
(0.0, "—".to_string(), format!("/ {total_msgs} msgs"))
} else {
(0.0, "—".to_string(), "—".to_string())
};
let color = if pct_val >= 90.0 {
"var(--limit-danger)"
} else if pct_val >= 70.0 {
"var(--limit-warn)"
} else {
"var(--limit-ok)"
};
format!(
r#"<div class="limit-bar-item">
<div class="limit-bar-header"><span class="limit-bar-label">{label}</span><span class="limit-bar-pct">{pct_text}</span></div>
<div class="limit-bar-track"><div class="limit-bar-fill" style="width:{pct_val:.1}%;background:{color}"></div></div>
<div class="limit-bar-sub">{used_text}</div>
</div>"#
)
}
fn render_other_tools(tools: &[ToolSummary], lang: Language) -> String {
let mut rows = String::new();
for t in tools {
let sessions = if t.sessions_7d > 0 {
if lang == Language::Tr {
format!("bu hafta {}", t.sessions_7d)
} else {
format!("{} this week", t.sessions_7d)
}
} else {
"—".to_string()
};
let tokens = if t.tokens_7d > 0 {
format_tokens(t.tokens_7d)
} else {
"—".to_string()
};
let last = t.last_used.as_deref().map(format_time).unwrap_or_else(|| "—".to_string());
let model = t.last_model.as_deref().unwrap_or("—");
rows.push_str(&format!(
r#"<tr><td><strong>{}</strong></td><td>{}</td><td class="num">{}</td><td class="muted">{}</td><td class="muted">{}</td></tr>"#,
html_escape(&t.name),
html_escape(model),
tokens,
sessions,
html_escape(&last),
));
}
format!(
r#"<section class="other-tools"><h2>{}</h2><div class="table-card wide"><table><thead><tr><th>{}</th><th>{}</th><th>{}</th><th>{}</th><th>{}</th></tr></thead><tbody>{rows}</tbody></table></div></section>"#,
lang.text("Other AI Tools", "Diğer AI Araçları"),
lang.text("tool", "araç"),
lang.text("last model", "son model"),
lang.text("7d tokens", "7g token"),
lang.text("7d sessions", "7g oturum"),
lang.text("last used", "son kullanım"),
)
}
fn today_agent(name: &str, usage: &AgentUsage, lang: Language) -> String {
let mut out = String::new();
out.push_str(&format!(
r#"<article class="agent-card"><div class="agent-heading"><h2>{}</h2><span class="status-pill" data-active="{}">{}</span></div>"#,
html_escape(name),
usage.active_session_started_at.is_some(),
if usage.active_session_started_at.is_some() {
lang.text("active", "aktif")
} else {
lang.text("idle", "boşta")
}
));
out.push_str(r#"<div class="metric-grid">"#);
out.push_str(&metric(lang.text("Active session tokens", "Aktif oturum token"), &active_session_value(usage, lang)));
out.push_str(&metric(lang.text("30d tokens", "30g token"), &format_tokens(usage.total_tokens_30d)));
out.push_str(&metric(
lang.text("30d sessions", "30g oturum"),
&usage.total_sessions_30d.to_string(),
));
out.push_str(&metric(
lang.text("Est. 30d cost", "Tahmini 30g maliyet"),
&format_usd(usage.total_cost_30d),
));
out.push_str(&metric(
lang.text("Est. today cost", "Tahmini bugünkü maliyet"),
&format_usd(usage.cost_today),
));
out.push_str(&metric(
lang.text("Last model", "Son model"),
usage.last_model.as_deref().unwrap_or("-"),
));
out.push_str(&metric(lang.text("Context", "Bağlam"), &format_pct(usage.last_context_pct)));
out.push_str(&metric(lang.text("Last turn", "Son tur"), &last_turn_value(usage)));
out.push_str(r#"</div></article>"#);
out
}
fn active_session_value(usage: &AgentUsage, lang: Language) -> String {
let tokens = format_tokens(usage.active_session_tokens);
match usage.active_session_started_at.as_deref() {
Some(started) if usage.active_session_tokens > 0 => format!(
r#"{tokens}<br><span class="muted">since {}</span>"#,
html_escape(&format!("{} {}", lang.text("since", "beri"), format_time(started)))
),
Some(started) => format!(
r#"0<br><span class="muted">since {}</span>"#,
html_escape(&format!("{} {}", lang.text("since", "beri"), format_time(started)))
),
None if usage.active_session_tokens > 0 => tokens,
None => "-".to_string(),
}
}
fn last_turn_value(usage: &AgentUsage) -> String {
usage
.last_turn_at
.as_deref()
.map(format_time)
.unwrap_or_else(|| "-".to_string())
}
fn metric(label: &str, value: &str) -> String {
format!(
r#"<div class="metric"><div class="label">{}</div><div class="value">{}</div></div>"#,
html_escape(label),
value
)
}
fn render_history(snap: &UsageSnapshot, lang: Language) -> String {
format!(
r#"<div class="stack">{}{}</div>"#,
history_agent("Claude", &snap.claude, lang),
history_agent("Codex", &snap.codex, lang)
)
}
fn history_agent(name: &str, usage: &AgentUsage, lang: Language) -> String {
let mut out = String::new();
out.push_str(&format!(
r#"<section class="agent-section"><h2>{}</h2><div class="charts">"#,
html_escape(name)
));
out.push_str(&chart_card(lang.text("Daily", "Günlük"), &usage.by_day, lang));
out.push_str(&chart_card(lang.text("Weekly", "Haftalık"), &usage.by_week, lang));
out.push_str(&chart_card(lang.text("Monthly", "Aylık"), &usage.by_month, lang));
out.push_str(r#"</div></section>"#);
out
}
fn render_sessions(snap: &UsageSnapshot, lang: Language) -> String {
format!(
r#"<div class="stack">{}{}</div>"#,
sessions_agent("Claude", &snap.claude.recent_sessions, lang),
sessions_agent("Codex", &snap.codex.recent_sessions, lang)
)
}
fn sessions_agent(name: &str, sessions: &[SessionRecord], lang: Language) -> String {
format!(
r#"<section class="agent-section"><h2>{}</h2>{}</section>"#,
html_escape(name),
recent_sessions_table(sessions, lang)
)
}
fn render_breakdown(snap: &UsageSnapshot, lang: Language) -> String {
format!(
r#"<div class="stack">{}{}</div>"#,
breakdown_agent("Claude", &snap.claude, lang),
breakdown_agent("Codex", &snap.codex, lang)
)
}
fn breakdown_agent(name: &str, usage: &AgentUsage, lang: Language) -> String {
let mut out = String::new();
out.push_str(&format!(
r#"<section class="agent-section"><h2>{}</h2><div class="tables">"#,
html_escape(name)
));
out.push_str(&named_table(lang.text("By model", "Modele göre"), lang.text("model", "model"), &usage.by_model, lang));
out.push_str(&named_table(lang.text("By project", "Projeye göre"), lang.text("project", "proje"), &usage.by_project, lang));
out.push_str(r#"</div></section>"#);
out
}
fn chart_card(title: &str, buckets: &[TimeBucket], lang: Language) -> String {
if buckets.is_empty() {
return format!(
r#"<div class="chart-card"><h3>{}</h3><div class="empty">{}</div></div>"#,
html_escape(title),
lang.text("no data", "veri yok"),
);
}
let max_tokens = buckets.iter().map(|b| b.tokens).max().unwrap_or(1).max(1);
let bar_w = 18;
let gap = 4;
let w = (bar_w + gap) * buckets.len() + 40;
let h = 150;
let mut svg = format!(r#"<svg viewBox="0 0 {w} {h}" preserveAspectRatio="none" class="chart">"#);
for (i, b) in buckets.iter().enumerate() {
let bh = (b.tokens as f64 / max_tokens as f64 * (h as f64 - 34.0)).max(1.0);
let x = (i * (bar_w + gap)) as f64 + 20.0;
let y = h as f64 - 22.0 - bh;
svg.push_str(&format!(
r#"<rect x="{x}" y="{y}" width="{bar_w}" height="{bh}" rx="3" class="bar"><title>{}</title></rect>"#,
html_escape(&format!(
"{}\n{} tokens · {} sessions",
b.date,
b.tokens,
b.sessions
))
));
}
if let (Some(first), Some(last)) = (buckets.first(), buckets.last()) {
svg.push_str(&format!(
r#"<text x="20" y="{}" class="label-x start">{}</text><text x="{}" y="{}" class="label-x end">{}</text>"#,
h - 4,
html_escape(&first.date),
w - 20,
h - 4,
html_escape(&last.date)
));
}
svg.push_str("</svg>");
format!(
r#"<div class="chart-card"><h3>{}</h3>{svg}</div>"#,
html_escape(title)
)
}
fn named_table(title: &str, name_heading: &str, items: &[NamedBucket], lang: Language) -> String {
let mut rows = String::new();
let total: u64 = items.iter().map(|i| i.tokens).sum::<u64>().max(1);
for it in items.iter().take(12) {
let pct = it.tokens as f64 / total as f64 * 100.0;
rows.push_str(&format!(
r#"<tr><td>{}</td><td class="num">{}</td><td class="num">{}</td><td class="num cost">{}</td><td class="bar-cell"><div class="hbar" style="width:{:.1}%"></div><span>{:.1}%</span></td></tr>"#,
html_escape(&it.model),
it.sessions,
format_tokens(it.tokens),
format_usd(it.cost),
pct,
pct
));
}
if rows.is_empty() {
rows = format!(
r#"<tr><td colspan="5" class="empty">{}</td></tr>"#,
lang.text("no data", "veri yok")
);
}
format!(
r#"<div class="table-card"><h3>{}</h3><table><thead><tr><th>{}</th><th>{}</th><th>{}</th><th>{}</th><th>{}</th></tr></thead><tbody>{rows}</tbody></table></div>"#,
html_escape(title),
html_escape(name_heading),
lang.text("sessions", "oturum"),
lang.text("tokens", "token"),
lang.text("cost", "maliyet"),
lang.text("share", "pay"),
)
}
fn recent_sessions_table(items: &[SessionRecord], lang: Language) -> String {
let mut rows = String::new();
for s in items {
rows.push_str(&format!(
r#"<tr><td>{}</td><td>{}</td><td>{}</td><td>{:.0} min</td><td class="num">{}</td><td class="num cost">{}</td><td><code>{}</code></td></tr>"#,
html_escape(&format_time(&s.started_at)),
html_escape(&s.project),
html_escape(&s.model),
s.duration_minutes,
format_tokens(s.tokens),
format_usd(s.cost),
html_escape(&s.id)
));
}
if rows.is_empty() {
rows = format!(
r#"<tr><td colspan="7" class="empty">{}</td></tr>"#,
lang.text("no recent sessions", "yakın oturum yok")
);
}
format!(
r#"<div class="table-card wide"><table><thead><tr><th>{}</th><th>{}</th><th>{}</th><th>{}</th><th>{}</th><th>{}</th><th>{}</th></tr></thead><tbody>{rows}</tbody></table></div>"#,
lang.text("started", "başlangıç"),
lang.text("project", "proje"),
lang.text("model", "model"),
lang.text("duration", "süre"),
lang.text("tokens", "token"),
lang.text("cost", "maliyet"),
lang.text("session id", "oturum id"),
)
}
fn format_tokens(value: u64) -> String {
if value >= 1_000_000_000 {
format!("{:.2}B", value as f64 / 1_000_000_000.0)
} else if value >= 1_000_000 {
format!("{:.2}M", value as f64 / 1_000_000.0)
} else if value >= 1_000 {
format!("{:.1}k", value as f64 / 1_000.0)
} else {
value.to_string()
}
}
fn format_usd(value: f64) -> String {
if value <= 0.0 {
return "$0.00".to_string();
}
if value < 0.01 {
return "<$0.01".to_string();
}
let cents = (value * 100.0).round() as u64;
let dollars = cents / 100;
let frac = cents % 100;
format!("${}.{:02}", group_thousands(dollars), frac)
}
fn group_thousands(mut n: u64) -> String {
if n == 0 {
return "0".to_string();
}
let mut parts: Vec<String> = Vec::new();
while n > 0 {
parts.push(format!("{:03}", n % 1000));
n /= 1000;
}
parts.reverse();
parts.join(",").trim_start_matches('0').to_string()
}
fn short_model(id: &str) -> String {
let m = id.to_lowercase();
let label = if m.contains("opus-4-8") { "Opus 4.8" }
else if m.contains("opus-4-7") { "Opus 4.7" }
else if m.contains("opus-4-6") { "Opus 4.6" }
else if m.contains("opus-4-5") { "Opus 4.5" }
else if m.contains("opus-4-1") { "Opus 4.1" }
else if m.contains("opus-4") { "Opus 4" }
else if m.contains("sonnet-4-6") { "Sonnet 4.6" }
else if m.contains("sonnet-4-5") { "Sonnet 4.5" }
else if m.contains("sonnet-4") { "Sonnet 4" }
else if m.contains("haiku-4-5") { "Haiku 4.5" }
else if m.contains("haiku") { "Haiku" }
else if m.contains("mythos") { "Mythos" }
else if m.contains("gpt-5.5") { "GPT-5.5" }
else if m.contains("gpt-5.4") { "GPT-5.4" }
else if m.contains("gpt-5.3") { "GPT-5.3" }
else if m.contains("gpt-5.2") { "GPT-5.2" }
else if m.contains("gpt-5.1") { "GPT-5.1" }
else if m.contains("gpt-5") { "GPT-5" }
else { return id.to_string(); };
let suffix = if m.contains("codex") { " codex" }
else if m.contains("[1m]") || m.contains("-1m") { " (1M)" }
else { "" };
format!("{label}{suffix}")
}
fn format_pct(value: Option<f64>) -> String {
value
.map(|pct| format!("{pct:.0}%"))
.unwrap_or_else(|| "-".to_string())
}
fn format_time(raw: &str) -> String {
if raw.is_empty() {
return "-".to_string();
}
raw.get(..16).unwrap_or(raw).replace('T', " ")
}
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
const HEAD: &str = r#"<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>context-bar · usage</title>
<style>
:root {
color-scheme: light dark;
--bg: Canvas;
--text: CanvasText;
--accent: AccentColor;
/* light */
--panel: rgba(255,255,255,0.72);
--panel2: rgba(246,246,248,0.80);
--border: rgba(0,0,0,0.09);
--separator: rgba(0,0,0,0.06);
--muted: rgba(60,60,67,0.60);
--bar2: #007AFF;
--seg-bg: rgba(118,118,128,0.12);
--seg-thumb: rgba(255,255,255,0.95);
--seg-thumb-shadow: 0 1px 3px rgba(0,0,0,0.18);
--pill-active-bg: rgba(0,122,255,0.12);
--pill-active-text: #007AFF;
--pill-idle-bg: rgba(118,118,128,0.10);
--pill-idle-text: rgba(60,60,67,0.55);
}
@media (prefers-color-scheme: dark) {
:root {
--panel: rgba(40,40,44,0.70);
--panel2: rgba(28,28,32,0.75);
--border: rgba(255,255,255,0.09);
--separator: rgba(255,255,255,0.06);
--muted: rgba(235,235,245,0.50);
--bar2: #0A84FF;
--seg-bg: rgba(118,118,128,0.24);
--seg-thumb: rgba(72,72,76,0.98);
--seg-thumb-shadow: 0 1px 4px rgba(0,0,0,0.55);
--pill-active-bg: rgba(10,132,255,0.18);
--pill-active-text: #0A84FF;
--pill-idle-bg: rgba(118,118,128,0.18);
--pill-idle-text: rgba(235,235,245,0.45);
}
}
* { box-sizing: border-box; margin:0; padding:0; }
body {
min-height: 100vh;
background:
linear-gradient(to bottom, color-mix(in srgb, var(--panel) 72%, transparent), transparent 180px),
var(--bg);
color: var(--text);
font: 13px/1.45 -apple-system, "SF Pro Text", ui-sans-serif, sans-serif;
-webkit-font-smoothing: antialiased;
}
.app-shell {
min-height: 100vh;
}
/* ── Header ───────────────────────────────────────────── */
header {
padding: 18px 24px 12px;
border-bottom: 1px solid var(--separator);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
background: var(--panel);
position: sticky; top: 0; z-index: 10;
}
h1, h2, h3 { text-wrap: balance; }
.title-block {
max-width: 1180px;
margin: 0 auto;
}
h1 { font-size: 15px; font-weight: 600; letter-spacing: -0.01em; }
.muted { color: var(--muted); font-size: 11px; margin-top: 2px; }
/* ── Segmented Control (macOS NSSegmentedControl style) ── */
nav.tabs {
display: flex;
justify-content: center;
padding: 12px 24px 0;
border-bottom: 1px solid var(--separator);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
background: color-mix(in srgb, var(--panel) 86%, transparent);
position: sticky;
top: 58px;
z-index: 9;
}
.seg-ctrl {
display: inline-flex;
flex-wrap: wrap;
max-width: 100%;
background: var(--seg-bg);
border-radius: 8px;
padding: 2px;
gap: 1px;
}
.tab-btn {
background: none;
border: none;
padding: 5px 14px;
border-radius: 6px;
color: var(--muted);
cursor: pointer;
font: 12px/1.4 -apple-system, sans-serif;
font-weight: 500;
letter-spacing: -0.003em;
transition: color 0.1s;
-webkit-user-select: none;
white-space: nowrap;
}
.tab-btn.active {
background: var(--seg-thumb);
box-shadow: var(--seg-thumb-shadow);
color: var(--text);
font-weight: 590;
}
.tab-panel {
display: none;
padding: 20px 24px 34px;
max-width: 1228px;
margin: 0 auto;
}
.tab-panel.active { display: block; }
main { min-height: calc(100vh - 116px); }
/* ── Layout helpers ───────────────────────────────────── */
.stack { display: flex; flex-direction: column; gap: 14px; }
.today-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px,1fr)); gap: 14px; }
/* ── Cards ────────────────────────────────────────────── */
.agent-card, .agent-section {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 18px 20px;
backdrop-filter: blur(16px) saturate(160%);
-webkit-backdrop-filter: blur(16px) saturate(160%);
box-shadow:
0 1px 0 rgba(255,255,255,0.08) inset,
0 12px 28px rgba(0,0,0,0.05);
}
h2 { font-size: 14px; font-weight: 600; letter-spacing: -0.01em; }
h3 { font-size: 11px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.055em; margin-bottom: 10px; }
/* ── Agent heading + status pill ─────────────────────── */
.agent-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 16px; }
.status-pill {
border-radius: 999px;
padding: 2px 9px;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.status-pill[data-active="true"] { background: var(--pill-active-bg); color: var(--pill-active-text); }
.status-pill[data-active="false"] { background: var(--pill-idle-bg); color: var(--pill-idle-text); }
/* ── Metric grid ──────────────────────────────────────── */
.metric-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 10px; }
.metric {
background: var(--panel2);
border: 1px solid var(--border);
border-radius: 10px;
min-height: 80px;
padding: 11px 13px;
}
.metric .label { font-size: 10.5px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.055em; margin-bottom: 7px; }
.metric .value { font-size: 19px; line-height: 1.2; font-weight: 650; letter-spacing: -0.02em; overflow-wrap: anywhere; }
/* ── Charts ───────────────────────────────────────────── */
.charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px,1fr)); gap: 10px; margin-top: 14px; }
.chart-card {
background: var(--panel2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 13px;
min-height: 195px;
min-width: 0;
}
svg.chart { width: 100%; height: 148px; display: block; }
svg.chart rect.bar { fill: var(--bar2); opacity: 0.85; }
svg.chart rect.bar:hover { opacity: 1; }
svg.chart text.label-x { fill: var(--muted); font-size: 9.5px; }
svg.chart text.label-x.end { text-anchor: end; }
/* ── Tables ───────────────────────────────────────────── */
.tables { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px,1fr)); gap: 10px; margin-top: 14px; }
.table-card {
background: var(--panel2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 13px;
overflow-x: auto;
min-width: 0;
}
.table-card.wide { margin-top: 14px; }
.other-tools { margin-top: 20px; }
.other-tools h2 { margin-bottom: 10px; font-size: 11px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.055em; }
.accounts-section { margin-top: 20px; }
.accounts-section h2 { margin-bottom: 12px; font-size: 11px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.055em; }
.limit-account-header { font-size: 12px; font-weight: 500; margin-bottom: 10px; }
.limit-bars { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px,1fr)); gap: 10px; }
.limit-bar-item { background: var(--panel2); border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; }
.limit-bar-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 7px; }
.limit-bar-label { font-size: 11px; font-weight: 500; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; }
.limit-bar-pct { font-size: 18px; font-weight: 650; letter-spacing: -0.02em; }
.limit-bar-track { height: 6px; background: var(--seg-bg); border-radius: 999px; overflow: hidden; margin-bottom: 6px; }
.limit-bar-fill { height: 100%; border-radius: 999px; transition: width 0.3s ease; }
.limit-bar-sub { font-size: 10.5px; color: var(--muted); }
:root { --limit-ok: #34C759; --limit-warn: #FF9F0A; --limit-danger: #FF3B30; }
.plan-badge { border-radius: 999px; padding: 2px 8px; font-size: 10.5px; font-weight: 600; letter-spacing: 0.02em; }
.plan-badge.plan-pro { background: rgba(52,199,89,0.15); color: #34C759; }
.plan-badge.plan-max { background: rgba(10,132,255,0.15); color: #0A84FF; }
.plan-badge.plan-free { background: var(--pill-idle-bg); color: var(--muted); }
tr.active-account-row td { background: rgba(10,132,255,0.06); }
table { width: 100%; border-collapse: collapse; font-size: 11.5px; }
th { text-align: left; padding: 5px 8px; font-weight: 500; color: var(--muted); border-bottom: 1px solid var(--separator); white-space: nowrap; font-size: 11px; }
td { padding: 5px 8px; border-bottom: 1px solid var(--separator); vertical-align: top; }
tr:last-child td { border-bottom: none; }
td.num { text-align: right; font-variant-numeric: tabular-nums; }
code { font-family: "SF Mono", ui-monospace, "Menlo", monospace; font-size: 10.5px; color: var(--muted); }
.bar-cell { position: relative; min-width: 110px; padding-right: 46px; }
.bar-cell .hbar { height: 5px; background: var(--bar2); border-radius: 3px; opacity: 0.75; }
.bar-cell span { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); font-size: 10.5px; color: var(--muted); }
.empty { color: var(--muted); font-size: 11.5px; padding: 10px 8px; }
.cost-note { background: var(--panel2); border: 1px solid var(--border); border-radius: 10px; padding: 11px 13px; font-size: 11.5px; color: var(--muted); line-height: 1.5; }
.cost-tiles { margin-bottom: 4px; }
td.cost { font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; }
td.date { color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
@media (max-width: 920px) {
.today-grid,
.charts,
.tables {
grid-template-columns: 1fr;
}
}
@media (max-width: 680px) {
header,
nav.tabs {
padding-left: 14px;
padding-right: 14px;
}
nav.tabs {
justify-content: flex-start;
overflow-x: auto;
top: 54px;
}
.seg-ctrl {
flex-wrap: nowrap;
width: max-content;
}
.tab-panel {
padding: 14px;
}
.agent-card,
.agent-section,
.chart-card,
.table-card {
border-radius: 11px;
}
.today-grid,
.metric-grid {
grid-template-columns: 1fr;
}
.agent-heading {
align-items: flex-start;
flex-direction: column;
}
.status-pill {
align-self: flex-start;
}
.metric .value {
font-size: 17px;
}
table {
min-width: 560px;
}
}
@media (max-width: 480px) {
h1 {
font-size: 14px;
}
.muted,
h3,
th,
td,
code {
font-size: 10.5px;
}
.tab-btn {
padding: 5px 11px;
font-size: 11.5px;
}
.agent-card,
.agent-section {
padding: 15px 16px;
}
.metric {
min-height: 72px;
padding: 10px 11px;
}
.bar-cell {
min-width: 96px;
padding-right: 40px;
}
}
</style></head><body>"#;
const FOOT: &str = r#"<script>
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById('tab-' + btn.dataset.tab).classList.add('active');
});
});
</script></body></html>"#;