generated from coulomb/repo-seed
Add Economic Observatory web UI with ledger-backed API
Introduce ui/ dashboard (dark observatory layout), JSON API, and local dev server. All metrics load from expense and payment record ledgers. Links Claude design reference for visual alignment.
This commit is contained in:
@@ -26,6 +26,7 @@ Framework docs live at the repo root. The Coulomb MVP implementation lives in
|
||||
| Lint / format | none configured — match surrounding style |
|
||||
| Build | none |
|
||||
| Run: economics dashboard | `cd projects/coulomb-pricing && python3 -m observatory --period YYYY-MM` |
|
||||
| Run: observatory UI | `cd projects/coulomb-pricing && python3 -m observatory.server` → http://127.0.0.1:8765/ |
|
||||
| Workplan / hub sync | `cd ~/state-hub && make fix-consistency REPO=adaptive-pricing REPO_PATH=~/adaptive-pricing` |
|
||||
| Registry sanity | `grep -q '^version:' registry/indexes/capabilities.yaml && echo OK` |
|
||||
|
||||
|
||||
@@ -35,7 +35,12 @@ cd projects/coulomb-pricing
|
||||
python3 -m pytest -q
|
||||
python3 -m observatory --period 2026-06
|
||||
python3 -m observatory --period 2026-06 --output reports/economics-2026-06.md
|
||||
python3 -m observatory.server
|
||||
```
|
||||
|
||||
Open **http://127.0.0.1:8765/** for the Economic Observatory UI (served from
|
||||
`ui/`, data via `/api/dashboard`). Design reference:
|
||||
https://claude.ai/design/p/fb2eef8c-c1fc-4c75-bff4-3782552e5511
|
||||
|
||||
Manual ledgers will be replaced by Bubble (Sprint 2), Stripe (Sprint 3), and
|
||||
OpenRouter (Sprint 4) importers.
|
||||
98
projects/coulomb-pricing/observatory/api.py
Normal file
98
projects/coulomb-pricing/observatory/api.py
Normal file
@@ -0,0 +1,98 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .economics import build_liquidity_summary, build_snapshot
|
||||
from .load import (
|
||||
default_data_dir,
|
||||
latest_period,
|
||||
load_budget,
|
||||
load_expense_records,
|
||||
load_membership,
|
||||
load_monthly_ledger,
|
||||
load_payment_records,
|
||||
load_pricing_models,
|
||||
load_product,
|
||||
)
|
||||
|
||||
|
||||
def _serialize(value: Any) -> Any:
|
||||
if isinstance(value, Decimal):
|
||||
return str(value)
|
||||
if hasattr(value, "__dataclass_fields__"):
|
||||
return {key: _serialize(getattr(value, key)) for key in value.__dataclass_fields__}
|
||||
if isinstance(value, list):
|
||||
return [_serialize(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {key: _serialize(item) for key, item in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def _load_json_catalog(data_dir: Path, name: str) -> dict:
|
||||
path = data_dir / "infrastructure" / name
|
||||
if not path.exists():
|
||||
return {}
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def build_dashboard_payload(data_dir: Path | None = None, period: str | None = None) -> dict:
|
||||
root = data_dir or default_data_dir()
|
||||
product = load_product(root)
|
||||
budget = load_budget(root)
|
||||
models = load_pricing_models(root)
|
||||
members = load_membership(root)
|
||||
payments = load_payment_records(root)
|
||||
expenses = load_expense_records(root)
|
||||
ledger = load_monthly_ledger(root)
|
||||
target_period = period or latest_period(ledger)
|
||||
|
||||
snapshot = build_snapshot(target_period, product, models, members, payments, ledger)
|
||||
liquidity = build_liquidity_summary(budget, payments, ledger, target_period)
|
||||
payment_by_period = {record.period: record for record in payments}
|
||||
|
||||
history = []
|
||||
for month in sorted(ledger, key=lambda row: row.period):
|
||||
if month.period > target_period:
|
||||
continue
|
||||
payment = payment_by_period.get(month.period)
|
||||
net_payment = payment.net_amount if payment else Decimal("0")
|
||||
history.append(
|
||||
{
|
||||
"period": month.period,
|
||||
"active_members": month.active_members,
|
||||
"gross_revenue": month.gross_revenue,
|
||||
"infrastructure_cost": month.infrastructure_cost,
|
||||
"payment_processing_cost": month.payment_processing_cost,
|
||||
"total_platform_cost": month.total_platform_cost,
|
||||
"net_payment": net_payment,
|
||||
"net_liquidity": net_payment - month.infrastructure_cost,
|
||||
}
|
||||
)
|
||||
|
||||
return _serialize(
|
||||
{
|
||||
"design_reference": "https://claude.ai/design/p/fb2eef8c-c1fc-4c75-bff4-3782552e5511",
|
||||
"period": target_period,
|
||||
"product": product,
|
||||
"budget": budget,
|
||||
"snapshot": snapshot,
|
||||
"liquidity": liquidity,
|
||||
"history": history,
|
||||
"pricing_models": models,
|
||||
"members": members,
|
||||
"payments": payments,
|
||||
"expense_record_count": len(expenses),
|
||||
"infrastructure": {
|
||||
"domains": _load_json_catalog(root, "domains.json"),
|
||||
"virtual_servers": _load_json_catalog(root, "virtual_servers.json"),
|
||||
"stripe": _load_json_catalog(root, "stripe.json"),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def payload_json(data_dir: Path | None = None, period: str | None = None) -> str:
|
||||
return json.dumps(build_dashboard_payload(data_dir, period), indent=2)
|
||||
72
projects/coulomb-pricing/observatory/server.py
Normal file
72
projects/coulomb-pricing/observatory/server.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from .api import build_dashboard_payload
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
UI_DIR = ROOT / "ui"
|
||||
|
||||
|
||||
class ObservatoryHandler(BaseHTTPRequestHandler):
|
||||
data_dir: Path = ROOT / "data"
|
||||
|
||||
def _send(self, status: int, body: bytes, content_type: str) -> None:
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path == "/api/dashboard":
|
||||
query = parse_qs(parsed.query)
|
||||
period = query.get("period", [None])[0]
|
||||
payload = build_dashboard_payload(self.data_dir, period)
|
||||
self._send(200, json.dumps(payload).encode("utf-8"), "application/json")
|
||||
return
|
||||
|
||||
if parsed.path == "/":
|
||||
return self._serve_file(UI_DIR / "index.html", "text/html; charset=utf-8")
|
||||
|
||||
if parsed.path.startswith("/ui/"):
|
||||
relative = parsed.path.removeprefix("/ui/")
|
||||
target = UI_DIR / relative
|
||||
if target.exists() and target.is_file():
|
||||
content_type = "text/css" if target.suffix == ".css" else "application/javascript"
|
||||
return self._serve_file(target, f"{content_type}; charset=utf-8")
|
||||
|
||||
self._send(404, b"Not found", "text/plain")
|
||||
|
||||
def _serve_file(self, path: Path, content_type: str) -> None:
|
||||
self._send(200, path.read_bytes(), content_type)
|
||||
|
||||
def log_message(self, format: str, *args) -> None:
|
||||
return
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Coulomb Economic Observatory UI server")
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=8765)
|
||||
parser.add_argument("--data-dir", type=Path, default=ROOT / "data")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
ObservatoryHandler.data_dir = args.data_dir
|
||||
server = ThreadingHTTPServer((args.host, args.port), ObservatoryHandler)
|
||||
print(f"Economic Observatory UI: http://{args.host}:{args.port}/")
|
||||
print(f"API: http://{args.host}:{args.port}/api/dashboard")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopped.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
25
projects/coulomb-pricing/tests/test_api.py
Normal file
25
projects/coulomb-pricing/tests/test_api.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from observatory.api import build_dashboard_payload, payload_json
|
||||
|
||||
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
|
||||
|
||||
|
||||
def test_dashboard_payload_contains_live_ledger_totals() -> None:
|
||||
payload = build_dashboard_payload(DATA_DIR, "2026-06")
|
||||
|
||||
assert payload["period"] == "2026-06"
|
||||
assert payload["liquidity"]["remaining_budget"] == "659.12"
|
||||
assert payload["liquidity"]["cumulative_infrastructure_cost"] == "409.28"
|
||||
assert payload["snapshot"]["monthly_infrastructure_cost"] == "29.73"
|
||||
assert len(payload["history"]) == 18
|
||||
assert payload["expense_record_count"] == 58
|
||||
|
||||
|
||||
def test_payload_json_is_valid() -> None:
|
||||
parsed = json.loads(payload_json(DATA_DIR, "2026-06"))
|
||||
assert Decimal(parsed["payments"][0]["fees_amount"]) == Decimal("0.44")
|
||||
178
projects/coulomb-pricing/ui/app.js
Normal file
178
projects/coulomb-pricing/ui/app.js
Normal file
@@ -0,0 +1,178 @@
|
||||
const euro = (value) => `€${Number(value).toFixed(2)}`;
|
||||
|
||||
async function loadDashboard(period) {
|
||||
const query = period ? `?period=${encodeURIComponent(period)}` : "";
|
||||
const response = await fetch(`/api/dashboard${query}`);
|
||||
if (!response.ok) throw new Error("Failed to load dashboard");
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function setBadge(el, status) {
|
||||
el.textContent = status;
|
||||
el.classList.toggle("ok", status === "generating" || status === "neutral");
|
||||
}
|
||||
|
||||
function renderHero(data) {
|
||||
const { snapshot, liquidity } = data;
|
||||
const cards = [
|
||||
{
|
||||
label: "Remaining budget",
|
||||
value: euro(liquidity.remaining_budget),
|
||||
sub: `${liquidity.months_tracked} months tracked`,
|
||||
},
|
||||
{
|
||||
label: "Period net liquidity",
|
||||
value: euro(snapshot.period_net_liquidity),
|
||||
sub: snapshot.liquidity_status,
|
||||
},
|
||||
{
|
||||
label: "Active members",
|
||||
value: snapshot.active_members,
|
||||
sub: data.members[0]?.username ? `@${data.members[0].username}` : "—",
|
||||
},
|
||||
{
|
||||
label: "Member payment (gross)",
|
||||
value: euro(snapshot.monthly_revenue),
|
||||
sub: `Net ${euro(data.payments.at(-1)?.net_amount ?? 0)} after Stripe`,
|
||||
},
|
||||
{
|
||||
label: "Infrastructure / month",
|
||||
value: euro(snapshot.monthly_infrastructure_cost),
|
||||
sub: `Processing ${euro(snapshot.monthly_payment_processing_cost)}`,
|
||||
},
|
||||
{
|
||||
label: "Cumulative net liquidity",
|
||||
value: euro(liquidity.cumulative_net_liquidity),
|
||||
sub: liquidity.liquidity_status,
|
||||
},
|
||||
];
|
||||
|
||||
document.getElementById("hero-grid").innerHTML = cards
|
||||
.map(
|
||||
(card) => `
|
||||
<article class="hero-card">
|
||||
<div class="label">${card.label}</div>
|
||||
<div class="value">${card.value}</div>
|
||||
<div class="sub">${card.sub}</div>
|
||||
</article>`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderBudget(data) {
|
||||
const { liquidity } = data;
|
||||
const initial = Number(liquidity.initial_budget);
|
||||
const remaining = Number(liquidity.remaining_budget);
|
||||
const pct = Math.max(0, Math.min(100, (remaining / initial) * 100));
|
||||
document.getElementById("budget-fill").style.width = `${pct}%`;
|
||||
document.getElementById("budget-caption").textContent =
|
||||
remaining >= 0 ? "Within allocated budget" : "Over allocated budget";
|
||||
document.getElementById("budget-stats").innerHTML = [
|
||||
["Initial budget", euro(initial)],
|
||||
["Cumulative member payments (net)", euro(liquidity.cumulative_member_payments)],
|
||||
["Cumulative infrastructure", euro(liquidity.cumulative_infrastructure_cost)],
|
||||
["Cumulative processing", euro(liquidity.cumulative_payment_processing_cost)],
|
||||
]
|
||||
.map(
|
||||
([k, v]) => `
|
||||
<div class="stat">
|
||||
<div class="k">${k}</div>
|
||||
<div class="v">${v}</div>
|
||||
</div>`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderChart(history) {
|
||||
const max = Math.max(...history.map((row) => Math.abs(Number(row.net_liquidity))), 1);
|
||||
document.getElementById("liquidity-chart").innerHTML = history
|
||||
.map((row) => {
|
||||
const value = Number(row.net_liquidity);
|
||||
const width = (Math.abs(value) / max) * 50;
|
||||
const bar =
|
||||
value < 0
|
||||
? `<div class="chart-bar neg" style="width:${width}%"></div>`
|
||||
: `<div class="chart-bar pos" style="width:${width}%"></div>`;
|
||||
return `
|
||||
<div class="chart-row">
|
||||
<div class="chart-label">${row.period}</div>
|
||||
<div class="chart-bar-wrap">${bar}</div>
|
||||
<div class="chart-value">${euro(value)}</div>
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderInfra(data) {
|
||||
const domains = data.infrastructure.domains?.domains ?? [];
|
||||
const servers = data.infrastructure.virtual_servers?.servers ?? [];
|
||||
const stripe = data.infrastructure.stripe?.membership ?? {};
|
||||
const items = [
|
||||
...domains.map(
|
||||
(d) =>
|
||||
`<div class="infra-item"><strong>${d.name}</strong><span>${d.monthly_eur} EUR/mo · ${d.tld}</span></div>`
|
||||
),
|
||||
...servers.map(
|
||||
(s) =>
|
||||
`<div class="infra-item"><strong>${s.name}</strong><span>${s.monthly_eur} EUR/mo · since ${s.started}</span></div>`
|
||||
),
|
||||
`<div class="infra-item"><strong>Stripe · ${stripe.member_username ?? "member"}</strong><span>${stripe.gross_monthly_eur} gross · ${stripe.fee_monthly_eur} fee · ${stripe.net_payout_monthly_eur} net to ${data.infrastructure.stripe?.payout_account ?? "payout"}</span></div>`,
|
||||
];
|
||||
document.getElementById("infra-stack").innerHTML = `<div class="infra-list">${items.join("")}</div>`;
|
||||
}
|
||||
|
||||
function renderTables(data) {
|
||||
document.getElementById("history-body").innerHTML = data.history
|
||||
.map(
|
||||
(row) => `
|
||||
<tr>
|
||||
<td>${row.period}</td>
|
||||
<td class="num">${row.active_members}</td>
|
||||
<td class="num">${euro(row.gross_revenue)}</td>
|
||||
<td class="num">${euro(row.infrastructure_cost)}</td>
|
||||
<td class="num">${euro(row.payment_processing_cost)}</td>
|
||||
<td class="num">${euro(row.net_liquidity)}</td>
|
||||
</tr>`
|
||||
)
|
||||
.join("");
|
||||
|
||||
document.getElementById("pricing-body").innerHTML = data.pricing_models
|
||||
.map(
|
||||
(model) => `
|
||||
<tr>
|
||||
<td>${model.id}</td>
|
||||
<td>${model.name}</td>
|
||||
<td>${model.model_type}</td>
|
||||
<td>${model.status}</td>
|
||||
</tr>`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function populatePeriods(history, current) {
|
||||
const select = document.getElementById("period-select");
|
||||
select.innerHTML = [...history].reverse().map(
|
||||
(row) => `<option value="${row.period}" ${row.period === current ? "selected" : ""}>${row.period}</option>`
|
||||
);
|
||||
select.onchange = async () => {
|
||||
const next = await loadDashboard(select.value);
|
||||
render(next);
|
||||
};
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
document.getElementById("design-link").href = data.design_reference;
|
||||
setBadge(document.getElementById("liquidity-badge"), data.snapshot.liquidity_status);
|
||||
renderHero(data);
|
||||
renderBudget(data);
|
||||
renderChart(data.history);
|
||||
renderInfra(data);
|
||||
renderTables(data);
|
||||
populatePeriods(data.history, data.period);
|
||||
}
|
||||
|
||||
loadDashboard()
|
||||
.then(render)
|
||||
.catch((error) => {
|
||||
document.body.innerHTML = `<pre style="padding:24px;color:#fb7185">${error}</pre>`;
|
||||
});
|
||||
107
projects/coulomb-pricing/ui/index.html
Normal file
107
projects/coulomb-pricing/ui/index.html
Normal file
@@ -0,0 +1,107 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Coulomb Economic Observatory</title>
|
||||
<link rel="stylesheet" href="/ui/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">Adaptive Pricing · Coulomb Social MVP</p>
|
||||
<h1>Economic Observatory</h1>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<label>
|
||||
Period
|
||||
<select id="period-select"></select>
|
||||
</label>
|
||||
<span class="badge" id="liquidity-badge">—</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="hero-grid" id="hero-grid"></section>
|
||||
|
||||
<section class="panel budget-panel">
|
||||
<div class="panel-head">
|
||||
<h2>Liquidity & Budget</h2>
|
||||
<p id="budget-caption">Operator liquidity pool</p>
|
||||
</div>
|
||||
<div class="budget-bar">
|
||||
<div class="budget-fill" id="budget-fill"></div>
|
||||
</div>
|
||||
<div class="budget-stats" id="budget-stats"></div>
|
||||
</section>
|
||||
|
||||
<div class="split-grid">
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Monthly Net Liquidity</h2>
|
||||
<p>Member net payments minus infrastructure</p>
|
||||
</div>
|
||||
<div class="chart" id="liquidity-chart"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Infrastructure Stack</h2>
|
||||
<p>Domains, hosting, and Stripe reference</p>
|
||||
</div>
|
||||
<div id="infra-stack"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Monthly Ledger</h2>
|
||||
<p>Computed from expense and payment record tables</p>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Period</th>
|
||||
<th>Members</th>
|
||||
<th>Gross</th>
|
||||
<th>Infrastructure</th>
|
||||
<th>Processing</th>
|
||||
<th>Net liquidity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="history-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Pricing Model Registry</h2>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pricing-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="footer">
|
||||
<p>
|
||||
Design reference:
|
||||
<a id="design-link" href="#" target="_blank" rel="noreferrer">Claude design share</a>
|
||||
· Totals computed programmatically from ledgers · Customer cost-pass-through billing not active
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="/ui/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
309
projects/coulomb-pricing/ui/styles.css
Normal file
309
projects/coulomb-pricing/ui/styles.css
Normal file
@@ -0,0 +1,309 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0b1020;
|
||||
--panel: #121a2f;
|
||||
--panel-border: #24304d;
|
||||
--text: #e8edf8;
|
||||
--muted: #93a0bf;
|
||||
--accent: #5eead4;
|
||||
--accent-soft: rgba(94, 234, 212, 0.12);
|
||||
--warn: #fb7185;
|
||||
--warn-soft: rgba(251, 113, 133, 0.14);
|
||||
--ok: #86efac;
|
||||
--ok-soft: rgba(134, 239, 172, 0.14);
|
||||
--shadow: 0 18px 50px rgba(0, 0, 0, 0.28);
|
||||
font-family: "Segoe UI", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(94, 234, 212, 0.08), transparent 28%),
|
||||
radial-gradient(circle at top right, rgba(96, 165, 250, 0.08), transparent 24%),
|
||||
var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 20px 48px;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.panel,
|
||||
.hero-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 18px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
padding: 22px 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 6px;
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
select {
|
||||
background: #0d1426;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 10px 14px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
background: var(--warn-soft);
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.badge.ok {
|
||||
background: var(--ok-soft);
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.hero-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
padding: 18px 18px 16px;
|
||||
}
|
||||
|
||||
.hero-card .label {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.hero-card .value {
|
||||
font-size: 1.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.hero-card .sub {
|
||||
margin-top: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 20px 22px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.panel-head p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.split-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.budget-bar {
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
background: #0d1426;
|
||||
overflow: hidden;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.budget-fill {
|
||||
height: 100%;
|
||||
width: 0;
|
||||
background: linear-gradient(90deg, var(--accent), #60a5fa);
|
||||
transition: width 0.35s ease;
|
||||
}
|
||||
|
||||
.budget-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.stat .k {
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.stat .v {
|
||||
margin-top: 6px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.chart {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chart-row {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 1fr 72px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chart-label,
|
||||
.chart-value {
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.chart-value {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.chart-bar-wrap {
|
||||
height: 28px;
|
||||
background: #0d1426;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chart-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.chart-bar.neg {
|
||||
left: 50%;
|
||||
background: linear-gradient(90deg, transparent, var(--warn));
|
||||
}
|
||||
|
||||
.chart-bar.pos {
|
||||
right: 50%;
|
||||
background: linear-gradient(270deg, transparent, var(--ok));
|
||||
}
|
||||
|
||||
.infra-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.infra-item {
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.infra-item strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.infra-item span {
|
||||
color: var(--muted);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 11px 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
text-align: left;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
td.num,
|
||||
th.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.footer {
|
||||
color: var(--muted);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.split-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user