generated from coulomb/repo-seed
Integrate whynot-design into Economic Observatory UI
Vendor whynot-design Layer 1 (tokens, CSS) and Layer 2 (<wn-*> components) via scripts/sync-whynot-design.sh with a pinned ref. Migrate the observatory shell to canonical web components, keep observatory-specific layout in styles.css, and add vendor integrity tests plus correct JS MIME types on the dev server.
This commit is contained in:
@@ -9,10 +9,11 @@ async function loadDashboard(period) {
|
||||
|
||||
function setBadge(el, status) {
|
||||
el.textContent = status;
|
||||
el.classList.toggle("ok", status === "generating" || status === "neutral");
|
||||
el.active = status === "generating" || status === "neutral";
|
||||
el.draft = status === "burning";
|
||||
}
|
||||
|
||||
function renderHero(data) {
|
||||
function renderMetrics(data) {
|
||||
const { snapshot, liquidity } = data;
|
||||
const cards = [
|
||||
{
|
||||
@@ -47,14 +48,14 @@ function renderHero(data) {
|
||||
},
|
||||
];
|
||||
|
||||
document.getElementById("hero-grid").innerHTML = cards
|
||||
document.getElementById("metric-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>`
|
||||
<wn-card size="sm">
|
||||
<wn-eyebrow slot="header">${card.label}</wn-eyebrow>
|
||||
<div class="obs-metric__value">${card.value}</div>
|
||||
<span slot="footer">${card.sub}</span>
|
||||
</wn-card>`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
@@ -64,9 +65,13 @@ function renderBudget(data) {
|
||||
const initial = Number(liquidity.initial_budget);
|
||||
const remaining = Number(liquidity.remaining_budget);
|
||||
const pct = Math.max(0, Math.min(100, (remaining / initial) * 100));
|
||||
const banner = document.getElementById("budget-banner");
|
||||
const caption = remaining >= 0 ? "Within allocated budget" : "Over allocated budget";
|
||||
|
||||
document.getElementById("budget-fill").style.width = `${pct}%`;
|
||||
document.getElementById("budget-caption").textContent =
|
||||
remaining >= 0 ? "Within allocated budget" : "Over allocated budget";
|
||||
document.getElementById("budget-caption").textContent = caption;
|
||||
banner.variant = remaining < initial * 0.25 ? "warn" : "info";
|
||||
|
||||
document.getElementById("budget-stats").innerHTML = [
|
||||
["Initial budget", euro(initial)],
|
||||
["Cumulative member payments (net)", euro(liquidity.cumulative_member_payments)],
|
||||
@@ -74,11 +79,10 @@ function renderBudget(data) {
|
||||
["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>`
|
||||
([label, value]) => `
|
||||
<wn-field-row label="${label}" narrow>
|
||||
<span class="wn-field-row__value">${value}</span>
|
||||
</wn-field-row>`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
@@ -89,15 +93,15 @@ function renderChart(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>`;
|
||||
const barClass = value < 0 ? "obs-liquidity-row__bar--neg" : "obs-liquidity-row__bar--pos";
|
||||
const valueClass = value < 0 ? "obs-liquidity-row__value--neg" : "";
|
||||
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 class="obs-liquidity-row">
|
||||
<div class="obs-liquidity-row__period">${row.period}</div>
|
||||
<div class="obs-liquidity-row__bar-wrap">
|
||||
<div class="obs-liquidity-row__bar ${barClass}" style="width:${width}%"></div>
|
||||
</div>
|
||||
<div class="obs-liquidity-row__value ${valueClass}">${euro(value)}</div>
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
@@ -107,18 +111,19 @@ function renderInfra(data) {
|
||||
const domains = data.infrastructure.domains?.domains ?? [];
|
||||
const servers = data.infrastructure.virtual_servers?.servers ?? [];
|
||||
const stripe = data.infrastructure.stripe?.membership ?? {};
|
||||
const payout = data.infrastructure.stripe?.payout_account ?? "payout";
|
||||
const items = [
|
||||
...domains.map(
|
||||
(d) =>
|
||||
`<div class="infra-item"><strong>${d.name}</strong><span>${d.monthly_eur} EUR/mo · ${d.tld}</span></div>`
|
||||
`<wn-field-row label="${d.name}" narrow><span class="wn-field-row__value">${d.monthly_eur} EUR/mo · ${d.tld}</span></wn-field-row>`
|
||||
),
|
||||
...servers.map(
|
||||
(s) =>
|
||||
`<div class="infra-item"><strong>${s.name}</strong><span>${s.monthly_eur} EUR/mo · since ${s.started}</span></div>`
|
||||
`<wn-field-row label="${s.name}" narrow><span class="wn-field-row__value">${s.monthly_eur} EUR/mo · since ${s.started}</span></wn-field-row>`
|
||||
),
|
||||
`<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>`,
|
||||
`<wn-field-row label="Stripe · ${stripe.member_username ?? "member"}" narrow><span class="wn-field-row__value">${stripe.gross_monthly_eur} gross · ${stripe.fee_monthly_eur} fee · ${stripe.net_payout_monthly_eur} net to ${payout}</span></wn-field-row>`,
|
||||
];
|
||||
document.getElementById("infra-stack").innerHTML = `<div class="infra-list">${items.join("")}</div>`;
|
||||
document.getElementById("infra-stack").innerHTML = `<div class="obs-infra-list">${items.join("")}</div>`;
|
||||
}
|
||||
|
||||
function renderTables(data) {
|
||||
@@ -126,12 +131,12 @@ function renderTables(data) {
|
||||
.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>
|
||||
<td class="mono">${row.period}</td>
|
||||
<td class="mono">${row.active_members}</td>
|
||||
<td class="obs-num mono">${euro(row.gross_revenue)}</td>
|
||||
<td class="obs-num mono">${euro(row.infrastructure_cost)}</td>
|
||||
<td class="obs-num mono">${euro(row.payment_processing_cost)}</td>
|
||||
<td class="obs-num mono">${euro(row.net_liquidity)}</td>
|
||||
</tr>`
|
||||
)
|
||||
.join("");
|
||||
@@ -140,10 +145,10 @@ function renderTables(data) {
|
||||
.map(
|
||||
(model) => `
|
||||
<tr>
|
||||
<td>${model.id}</td>
|
||||
<td class="mono">${model.id}</td>
|
||||
<td>${model.name}</td>
|
||||
<td>${model.model_type}</td>
|
||||
<td>${model.status}</td>
|
||||
<td class="mono">${model.model_type}</td>
|
||||
<td><wn-tag>${model.status}</wn-tag></td>
|
||||
</tr>`
|
||||
)
|
||||
.join("");
|
||||
@@ -154,16 +159,35 @@ function populatePeriods(history, current) {
|
||||
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);
|
||||
};
|
||||
select.value = current;
|
||||
if (!select._obsBound) {
|
||||
select.addEventListener("wn-change", async (event) => {
|
||||
const next = await loadDashboard(event.detail.value);
|
||||
render(next);
|
||||
});
|
||||
select._obsBound = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDesignRefLabel() {
|
||||
try {
|
||||
const response = await fetch("/ui/vendor/whynot-design/.whynot-design-ref");
|
||||
if (!response.ok) return;
|
||||
const ref = (await response.text()).trim().slice(0, 7);
|
||||
const label = document.getElementById("design-ref-label");
|
||||
if (label && ref) label.textContent = `whynot-design @ ${ref}`;
|
||||
} catch {
|
||||
// optional footer detail
|
||||
}
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
document.getElementById("design-link").href = data.design_reference;
|
||||
loadDesignRefLabel();
|
||||
const header = document.getElementById("page-header");
|
||||
header.lede = `Period ${data.period}. Ledger-backed view of infrastructure spend, member payments, and remaining budget. Customer cost-pass-through billing is not active.`;
|
||||
setBadge(document.getElementById("liquidity-badge"), data.snapshot.liquidity_status);
|
||||
renderHero(data);
|
||||
renderMetrics(data);
|
||||
renderBudget(data);
|
||||
renderChart(data.history);
|
||||
renderInfra(data);
|
||||
@@ -174,5 +198,5 @@ function render(data) {
|
||||
loadDashboard()
|
||||
.then(render)
|
||||
.catch((error) => {
|
||||
document.body.innerHTML = `<pre style="padding:24px;color:#fb7185">${error}</pre>`;
|
||||
document.body.innerHTML = `<main class="wn-main"><wn-banner variant="error" title="Load failed">${error}</wn-banner></main>`;
|
||||
});
|
||||
Reference in New Issue
Block a user