384 lines
23 KiB
JavaScript
384 lines
23 KiB
JavaScript
(() => {
|
||
"use strict";
|
||
|
||
const params = new URLSearchParams(location.search);
|
||
const suppliedToken = params.get("token");
|
||
if (suppliedToken) sessionStorage.setItem("casan_viewer_token", suppliedToken);
|
||
const token = suppliedToken || sessionStorage.getItem("casan_viewer_token") || "";
|
||
params.delete("token");
|
||
history.replaceState({}, "", `${location.pathname}${params.size ? `?${params}` : ""}${location.hash}`);
|
||
|
||
const state = {
|
||
view: params.get("view") || (params.get("trace") ? "runs" : "overview"),
|
||
trace: params.get("trace") || "",
|
||
project: null,
|
||
registry: null,
|
||
h6: null,
|
||
run: null,
|
||
selectedGate: "H1-context",
|
||
};
|
||
const $ = (selector) => document.querySelector(selector);
|
||
const esc = (value) => String(value ?? "")
|
||
.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">")
|
||
.replaceAll('"', """).replaceAll("'", "'");
|
||
const slug = (value) => String(value || "unknown").toLowerCase().replaceAll("_", "-").replace(/[^a-z0-9-]/g, "");
|
||
const text = (value, fallback = "Unavailable") => value === null || value === undefined || value === "" ? fallback : String(value);
|
||
const number = (value) => value === null || value === undefined || !Number.isFinite(Number(value))
|
||
? "Unavailable" : new Intl.NumberFormat("en-US").format(Number(value));
|
||
const time = (value) => value ? String(value).replace("T", " ").replace("Z", " UTC") : "No timestamp";
|
||
const money = (value) => value === null || value === undefined ? "Unavailable" : `$${Number(value).toFixed(6)}`;
|
||
const pill = (value) => `<span class="pill ${slug(value)}">${esc(String(value || "unknown").replaceAll("_", " "))}</span>`;
|
||
|
||
async function api(path) {
|
||
const response = await fetch(path, {
|
||
headers: { "X-CASAN-Viewer-Token": token },
|
||
cache: "no-store",
|
||
});
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`);
|
||
return payload;
|
||
}
|
||
|
||
function downloadUrl(path, query = {}) {
|
||
const next = new URLSearchParams({ ...query, token });
|
||
return `${path}?${next}`;
|
||
}
|
||
|
||
function hero({ eyebrow, title, description, verdict, meta = [], actions = "" }) {
|
||
return `<article class="hero">
|
||
<div class="hero-grid">
|
||
<div>
|
||
<p class="eyebrow">${esc(eyebrow)}</p>
|
||
<h2>${esc(title)}</h2>
|
||
<p class="hero-description">${esc(description)}</p>
|
||
<div class="hero-meta">${meta.map((item) => `<span>${esc(item)}</span>`).join("")}</div>
|
||
</div>
|
||
<div class="hero-actions">${verdict ? pill(verdict) : ""}${actions}</div>
|
||
</div>
|
||
</article>`;
|
||
}
|
||
|
||
function metric(label, value, detail) {
|
||
return `<article class="metric-card"><div class="metric-label">${esc(label)}</div>
|
||
<div class="metric-value">${esc(text(value))}</div><div class="metric-detail">${esc(detail)}</div></article>`;
|
||
}
|
||
|
||
function panel(eyebrow, title, description, body, right = "") {
|
||
return `<article class="panel"><header class="panel-head"><div><p class="eyebrow">${esc(eyebrow)}</p>
|
||
<h2>${esc(title)}</h2><p class="panel-description">${esc(description)}</p></div>${right}</header>
|
||
<div class="panel-body">${body}</div></article>`;
|
||
}
|
||
|
||
function empty(title, detail) {
|
||
return `<div class="empty"><div class="empty-mark">∅</div><strong>${esc(title)}</strong><p>${esc(detail)}</p></div>`;
|
||
}
|
||
|
||
function setView(view, push = true) {
|
||
state.view = ["overview", "runs", "h6"].includes(view) ? view : "overview";
|
||
document.querySelectorAll(".nav-item").forEach((item) => {
|
||
const active = item.dataset.view === state.view;
|
||
item.classList.toggle("active", active);
|
||
if (active) item.setAttribute("aria-current", "page");
|
||
else item.removeAttribute("aria-current");
|
||
});
|
||
document.querySelectorAll(".view").forEach((item) => item.classList.add("hidden"));
|
||
$(`#${state.view}-view`).classList.remove("hidden");
|
||
$("#page-title").textContent = {
|
||
overview: "Assurance overview", runs: "Run assurance registry", h6: "H6 AgentOps dossier",
|
||
}[state.view];
|
||
if (push) {
|
||
const next = new URL(location.href);
|
||
next.searchParams.set("view", state.view);
|
||
if (state.trace) next.searchParams.set("trace", state.trace);
|
||
else next.searchParams.delete("trace");
|
||
history.pushState({}, "", `${next.pathname}?${next.searchParams}`);
|
||
}
|
||
if (state.view === "runs") renderRuns();
|
||
if (state.view === "h6") renderH6();
|
||
closeNav();
|
||
}
|
||
|
||
function renderOverview() {
|
||
const profile = state.project;
|
||
const registry = state.registry;
|
||
const latest = registry.latest || {};
|
||
const recent = registry.runs || [];
|
||
const failures = recent.filter((row) => row.status === "failed").length;
|
||
const certified = latest.certified === true;
|
||
const latestTrace = latest.trace_id || recent[0]?.trace_id;
|
||
const maturity = profile.maturity || {};
|
||
const actions = latestTrace
|
||
? `<button class="button primary" type="button" data-open-trace="${esc(latestTrace)}">Inspect latest run</button>
|
||
<button class="button secondary" type="button" data-open-h6="${esc(latestTrace)}">Open H6</button>`
|
||
: `<button class="button secondary" type="button" data-view-target="runs">View registry</button>`;
|
||
$("#overview-view").innerHTML =
|
||
hero({
|
||
eyebrow: "CASAN Core · Local assurance",
|
||
title: latestTrace ? "One prompt. One evidence trail." : "Ready for the first governed run.",
|
||
description: "A read-only, offline-capable assurance workspace reconstructed from canonical project evidence. No Platform service or HTML generation on the prompt hot path.",
|
||
verdict: latestTrace ? (certified ? "certified" : "review required") : "ready",
|
||
meta: [
|
||
`${profile.project_name} · ${profile.edition}`,
|
||
latestTrace ? `Trace ${latestTrace}` : "No finalized trace yet",
|
||
`Maturity ${maturity.level == null ? "not assessed" : `L${maturity.level}`} · ${maturity.status}`,
|
||
],
|
||
actions,
|
||
}) +
|
||
`<section class="metric-grid">
|
||
${metric("Governed records", number(registry.count), `${recent.length} recent records loaded`)}
|
||
${metric("Latest H6 quality", latest.telemetry_quality || "Unavailable", "Missing values are disclosed, never coerced to zero")}
|
||
${metric("Observed failures", number(failures), `${recent.length ? Math.round((failures / recent.length) * 100) : 0}% of loaded records`)}
|
||
${metric("Evidence gates", latestTrace ? "H1 → H7" : "Waiting", latestTrace ? "Control-by-control reconstruction available" : "Evidence appears after a prompt finalizes")}
|
||
</section>` +
|
||
panel(
|
||
"Product boundary",
|
||
"Core is visual by default",
|
||
"The local viewer covers the complete single-project review workflow; centralized operations remain a Platform capability.",
|
||
`<div class="two-col">
|
||
<div class="notice success"><strong>Included in Core</strong><br>Latest run, history, interactive H1–H7 evidence, H6 AgentOps, and on-demand JSON/HTML export.</div>
|
||
<div class="notice warning"><strong>Platform boundary</strong><br>Multi-project fleet views, centralized RBAC, shared approvals, ingestion and organization-wide retention.</div>
|
||
</div>`
|
||
) +
|
||
recentRunsPanel(recent.slice(0, 6), "Latest governed evidence");
|
||
bindActions();
|
||
}
|
||
|
||
function recentRunsPanel(rows, title = "Recent governed runs") {
|
||
const body = rows.length ? `<div class="table-scroll"><table>
|
||
<thead><tr><th>Observed at</th><th>Lifecycle step</th><th>Verdict</th><th class="right">Latency</th><th class="right">Tokens</th><th class="right">Evidence</th></tr></thead>
|
||
<tbody>${rows.map((row) => `<tr>
|
||
<td class="mono">${esc(time(row.timestamp))}</td><td><strong>${esc(row.step || row.harness || "Agentic turn")}</strong></td>
|
||
<td>${pill(row.status)}</td><td class="right mono">${esc(row.latency_ms == null ? "Unavailable" : `${number(row.latency_ms)} ms`)}</td>
|
||
<td class="right mono">${esc(number(row.total_tokens))}</td>
|
||
<td class="right">${row.trace_id ? `<button class="button light" type="button" data-open-trace="${esc(row.trace_id)}">Inspect H1–H7</button>` : "—"}</td>
|
||
</tr>`).join("")}</tbody></table></div>` : empty("No governed run yet", "Complete a prompt in a configured client, then refresh this viewer.");
|
||
return panel("Evidence index", title, "Operational values are shown only when present in canonical telemetry.", body);
|
||
}
|
||
|
||
function renderRuns() {
|
||
const rows = state.registry.runs || [];
|
||
const selected = state.run;
|
||
const body = hero({
|
||
eyebrow: "Run assurance registry",
|
||
title: "Every governed run, one evidence trail.",
|
||
description: "Select a lifecycle record to reconstruct H1–H7 decisions, inspect sanitized evidence and export an independent dossier.",
|
||
verdict: rows.some((row) => row.status === "failed") ? "attention" : "operational",
|
||
meta: [`${state.registry.count} records`, `${rows.filter((row) => row.status === "failed").length} failures`, "Local canonical evidence"],
|
||
actions: `<button class="button primary" type="button" data-view-target="h6">Open H6 dossier</button>`,
|
||
}) + (selected ? renderTrace(selected) : "") + recentRunsPanel(rows);
|
||
$("#runs-view").innerHTML = body;
|
||
bindActions();
|
||
bindGates();
|
||
}
|
||
|
||
function renderTrace(report) {
|
||
const gates = report.gates || [];
|
||
let selected = gates.find((gate) => gate.id === state.selectedGate) || gates[0];
|
||
if (!selected) return "";
|
||
const rail = gates.map((gate, index) => `<button type="button" aria-pressed="${gate.id === selected.id}" class="gate-button ${gate.id === selected.id ? "selected" : ""}" data-gate="${esc(gate.id)}">
|
||
<span class="gate-index">0${index + 1}</span><span class="gate-name">${esc(gate.title)}</span>
|
||
<span class="gate-state"><span class="state-dot ${slug(gate.status)}"></span>${esc(gate.status)}</span></button>`).join("");
|
||
const events = (selected.events || []).slice(-4).reverse();
|
||
const timeline = events.length ? `<div class="timeline">${events.map((event) => `<div class="timeline-item">
|
||
<div class="timeline-line"><span class="state-dot ${slug(event.status)}"></span></div>
|
||
<div class="timeline-copy"><strong>${esc(event.status)}</strong><time>${esc(time(event.timestamp))}</time><p>${esc(event.reason)}</p></div>
|
||
</div>`).join("")}</div>` : `<p class="metric-detail">No individual event was emitted for this control.</p>`;
|
||
const entries = Object.entries(selected.evidence || {});
|
||
const manifest = entries.length ? `<dl>${entries.map(([key, value]) => `<div class="manifest-row"><dt>${esc(key)}</dt><dd>${esc(typeof value === "string" ? value : JSON.stringify(value))}</dd></div>`).join("")}</dl>`
|
||
: empty("No safe evidence fields", "This control has not emitted a sanitized evidence manifest.");
|
||
const actions = `<a class="button secondary" href="${downloadUrl(`/api/v1/reports/run/${encodeURIComponent(report.trace_id)}/export`, { format: "json" })}">JSON evidence</a>
|
||
<a class="button primary" href="${downloadUrl(`/api/v1/reports/run/${encodeURIComponent(report.trace_id)}/export`, { format: "html" })}">Export dossier</a>`;
|
||
return panel(
|
||
"Run assurance",
|
||
"Governed execution dossier",
|
||
"A control-by-control reconstruction backed by sanitized lifecycle evidence.",
|
||
`<div class="hero trace-hero">
|
||
<div class="hero-grid"><div><p class="eyebrow">Trace provenance</p><h2 class="trace-title">${esc(report.trace_id)}</h2>
|
||
<div class="hero-meta trace-meta"><span>${report.summary.gates_observed}/7 controls observed</span><span>${report.project.edition} edition</span></div></div>
|
||
<div>${pill(report.verdict)}</div></div></div>
|
||
<div class="rail-wrap rail-spaced"><div class="evidence-rail">${rail}</div></div>
|
||
<div class="dossier">
|
||
<div class="decision-card"><div><p class="eyebrow">Selected control</p><h2>${esc(selected.title)}</h2><p class="panel-description">${esc(selected.description)}</p></div>
|
||
<div class="inset"><span class="inset-label">Latest decision · ${esc(time(selected.updated_at))}</span><p>${esc(selected.reason)}</p></div>
|
||
<div class="inset"><span class="inset-label">Event history</span>${timeline}</div>
|
||
</div>
|
||
<div class="manifest"><div class="manifest-head"><div><p class="eyebrow">Safe evidence manifest</p><div class="metric-detail">Sanitized fields persisted by the harness</div></div>${pill(`${entries.length} fields`)}</div>${manifest}</div>
|
||
</div>`,
|
||
actions,
|
||
);
|
||
}
|
||
|
||
function renderH6() {
|
||
const data = state.h6;
|
||
if (!data) return;
|
||
const query = data.scope || {};
|
||
const queryObject = Object.fromEntries(Object.entries(query).filter(([, value]) => value));
|
||
const actions = `<a class="button secondary" href="${downloadUrl("/api/v1/reports/h6/export", { ...queryObject, format: "json" })}">JSON evidence</a>
|
||
<a class="button primary" href="${downloadUrl("/api/v1/reports/h6/export", { ...queryObject, format: "html" })}">Export dossier</a>`;
|
||
const findings = data.findings.length ? `<div class="findings">${data.findings.map((item, index) => `<article class="finding ${slug(item.severity)}">
|
||
<span class="finding-index">${String(index + 1).padStart(2, "0")}</span><div><code>${esc(item.code)}</code><p>${esc(item.message)}</p></div></article>`).join("")}</div>`
|
||
: `<div class="notice success">No threshold breach was detected in the selected evidence boundary.</div>`;
|
||
const sources = data.evidence_sources.map((source) => {
|
||
const status = !source.present ? "missing" : source.stale ? "stale" : "fresh";
|
||
return `<article class="source"><div class="source-top"><div><h3>${esc(source.source)}</h3><div class="source-meta">${source.records} records · ${source.age_s == null ? "no timestamp" : `${number(source.age_s)}s old`}</div></div>${pill(status)}</div>
|
||
<div class="source-path">${esc(source.path)}</div></article>`;
|
||
}).join("");
|
||
const breakdown = (rows, subject) => `<div class="table-scroll"><table><thead><tr><th>${esc(subject)}</th><th class="right">Runs</th><th class="right">Failures</th><th class="right">Avg latency</th><th class="right">Tokens</th><th class="right">Cost</th></tr></thead>
|
||
<tbody>${rows.length ? rows.map((row) => `<tr><td><strong>${esc(row.key)}</strong></td><td class="right mono">${row.runs}</td><td class="right mono">${row.failures}</td><td class="right mono">${number(row.latency_avg_ms)} ms</td><td class="right mono">${number(row.tokens)}</td><td class="right mono">${money(row.cost_usd)}</td></tr>`).join("") : `<tr><td colspan="6">${empty("No matching records", `No ${subject.toLowerCase()} evidence is available.`)}</td></tr>`}</tbody></table></div>`;
|
||
$("#h6-view").innerHTML =
|
||
hero({
|
||
eyebrow: "H6 · AgentOps assurance",
|
||
title: "Operational evidence, ready for a decision.",
|
||
description: "Runtime health, reliability, cost provenance and telemetry integrity—designed to answer what happened, why it matters and where the evidence lives.",
|
||
verdict: data.verdict,
|
||
meta: [data.report_id, `Generated ${time(data.generated_at)}`, `Freshness ${data.freshness.status}`],
|
||
actions,
|
||
}) +
|
||
panel("Scope", "Evidence boundary", "Filters apply to the screen and both export formats.", h6Filters(data)) +
|
||
`<section class="metric-grid">
|
||
${metric("Governed runs", number(data.summary.runs), `${data.summary.success} successful · ${data.summary.degraded} degraded`)}
|
||
${metric("Failure rate", `${data.summary.failure_rate_pct}%`, `${data.summary.failed} failed · ${data.summary.alerts} alerts`)}
|
||
${metric("P95 latency", `${number(data.summary.latency_ms.p95)} ms`, `P50 ${number(data.summary.latency_ms.p50)} ms`)}
|
||
${metric("Telemetry integrity", data.data_quality.status.replaceAll("_", " "), `${data.summary.coverage.token_pct}% token · ${data.summary.coverage.cost_pct}% cost`)}
|
||
</section>` +
|
||
`<div class="two-col">
|
||
${panel("Decision brief", "What requires attention", "Threshold findings are review signals, not maturity claims.", findings, pill(data.verdict))}
|
||
${panel("Source integrity", "Evidence provenance", "Freshness is evaluated independently for each canonical source.", `<div class="source-grid">${sources}</div>`, pill(data.freshness.status))}
|
||
</div>` +
|
||
`<div class="breakdown-grid">
|
||
${panel("Runtime", "Execution profile by step", "Latency, failure and attribution by governed lifecycle step.", breakdown(data.details.by_step, "Step"))}
|
||
${panel("Provider", "Model usage and provenance", "Only provider-reported token and actual cost values are shown.", breakdown(data.details.by_provider, "Provider · model"))}
|
||
</div>` +
|
||
panel("Evidence quality", "Coverage and disclosure", "Unavailable telemetry remains null. Coverage gaps stay visible.", `<div class="two-col">
|
||
<div class="coverage">${coverage("Token attribution", data.summary.coverage.token_pct, `${data.summary.coverage.token_records}/${data.summary.coverage.runtime_records} records`)}
|
||
${coverage("Cost attribution", data.summary.coverage.cost_pct, `${data.summary.coverage.cost_records}/${data.summary.coverage.runtime_records} records`)}</div>
|
||
<div class="inset"><span class="inset-label">Disclosure notes</span>${data.data_quality.warnings.length ? `<ul>${data.data_quality.warnings.map((warning) => `<li class="metric-detail">${esc(warning)}</li>`).join("")}</ul>` : `<p class="success-copy">All required sources are present with no estimation warning.</p>`}</div>
|
||
</div>`, pill(data.data_quality.status));
|
||
bindH6Filters();
|
||
}
|
||
|
||
function coverage(label, value, detail) {
|
||
const width = Math.round(Math.max(0, Math.min(100, Number(value) || 0)) / 5) * 5;
|
||
return `<div><div class="coverage-head"><strong>${esc(label)}</strong><span>${esc(value)}%</span></div>
|
||
<div class="coverage-track"><div class="coverage-fill width-${width}"></div></div>
|
||
<div class="metric-detail">${esc(detail)}</div></div>`;
|
||
}
|
||
|
||
function h6Filters(data) {
|
||
const scope = data.scope || {};
|
||
const options = data.available_filters || { projects: [], runs: [] };
|
||
const date = (value) => value ? value.slice(0, 10) : "";
|
||
return `<form id="h6-filters" class="filters">
|
||
<label class="field"><span>Project</span><select name="project"><option value="">All projects</option>${options.projects.map((value) => `<option value="${esc(value)}" ${scope.project === value ? "selected" : ""}>${esc(value)}</option>`).join("")}</select></label>
|
||
<label class="field"><span>From</span><input type="date" name="from" value="${esc(date(scope.from))}"></label>
|
||
<label class="field"><span>To</span><input type="date" name="to" value="${esc(date(scope.to))}"></label>
|
||
<label class="field"><span>Run / trace</span><input name="run" list="run-options" value="${esc(scope.run || "")}" placeholder="All governed runs"><datalist id="run-options">${options.runs.map((value) => `<option value="${esc(value)}"></option>`).join("")}</datalist></label>
|
||
<div class="filter-actions"><button class="button primary" type="submit">Apply</button><button class="button light" type="button" id="clear-h6">Clear</button></div>
|
||
</form>`;
|
||
}
|
||
|
||
async function loadTrace(traceId) {
|
||
if (!traceId) return;
|
||
state.trace = traceId;
|
||
state.selectedGate = "H1-context";
|
||
$("#loading").classList.remove("hidden");
|
||
try {
|
||
state.run = await api(`/api/v1/reports/run/${encodeURIComponent(traceId)}`);
|
||
setView("runs");
|
||
} catch (error) {
|
||
showError(`Run report could not be reconstructed: ${error.message}`);
|
||
} finally {
|
||
$("#loading").classList.add("hidden");
|
||
}
|
||
}
|
||
|
||
async function loadH6(query = {}) {
|
||
const next = new URLSearchParams(Object.fromEntries(Object.entries(query).filter(([, value]) => value)));
|
||
$("#loading").classList.remove("hidden");
|
||
try {
|
||
state.h6 = await api(`/api/v1/reports/h6${next.size ? `?${next}` : ""}`);
|
||
renderH6();
|
||
} catch (error) {
|
||
showError(`H6 report could not be generated: ${error.message}`);
|
||
} finally {
|
||
$("#loading").classList.add("hidden");
|
||
}
|
||
}
|
||
|
||
function bindActions() {
|
||
document.querySelectorAll("[data-view-target]").forEach((button) => button.addEventListener("click", () => setView(button.dataset.viewTarget)));
|
||
document.querySelectorAll("[data-open-trace]").forEach((button) => button.addEventListener("click", () => loadTrace(button.dataset.openTrace)));
|
||
document.querySelectorAll("[data-open-h6]").forEach((button) => button.addEventListener("click", async () => {
|
||
await loadH6({ run: button.dataset.openH6 });
|
||
setView("h6");
|
||
}));
|
||
}
|
||
|
||
function bindGates() {
|
||
document.querySelectorAll("[data-gate]").forEach((button) => button.addEventListener("click", () => {
|
||
state.selectedGate = button.dataset.gate;
|
||
renderRuns();
|
||
}));
|
||
}
|
||
|
||
function bindH6Filters() {
|
||
$("#h6-filters")?.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
const values = Object.fromEntries(new FormData(event.currentTarget));
|
||
await loadH6(values);
|
||
});
|
||
$("#clear-h6")?.addEventListener("click", () => loadH6());
|
||
}
|
||
|
||
function showError(message) {
|
||
$("#error").textContent = message;
|
||
$("#error").classList.remove("hidden");
|
||
}
|
||
|
||
function closeNav() {
|
||
$(".sidebar").classList.remove("open");
|
||
$("#nav-scrim").classList.add("hidden");
|
||
$("#menu-button").setAttribute("aria-expanded", "false");
|
||
}
|
||
|
||
async function loadAll() {
|
||
$("#error").classList.add("hidden");
|
||
$("#loading").classList.remove("hidden");
|
||
try {
|
||
[state.project, state.registry, state.h6] = await Promise.all([
|
||
api("/api/v1/project"),
|
||
api("/api/v1/runs?limit=100"),
|
||
api("/api/v1/reports/h6?limit=50"),
|
||
]);
|
||
$("#freshness-pill").textContent = `${state.h6.freshness.status} evidence`;
|
||
$("#freshness-pill").className = `pill ${slug(state.h6.freshness.status)}`;
|
||
renderOverview();
|
||
renderH6();
|
||
if (state.trace) {
|
||
await loadTrace(state.trace);
|
||
} else {
|
||
setView(state.view, false);
|
||
}
|
||
} catch (error) {
|
||
showError(`Local evidence could not be loaded: ${error.message}`);
|
||
} finally {
|
||
$("#loading").classList.add("hidden");
|
||
}
|
||
}
|
||
|
||
document.querySelectorAll(".nav-item").forEach((button) => button.addEventListener("click", () => setView(button.dataset.view)));
|
||
$("#refresh-button").addEventListener("click", loadAll);
|
||
$("#menu-button").addEventListener("click", () => {
|
||
const open = !$(".sidebar").classList.contains("open");
|
||
$(".sidebar").classList.toggle("open", open);
|
||
$("#nav-scrim").classList.toggle("hidden", !open);
|
||
$("#menu-button").setAttribute("aria-expanded", String(open));
|
||
});
|
||
$("#nav-scrim").addEventListener("click", closeNav);
|
||
document.addEventListener("keydown", (event) => {
|
||
if (event.key === "Escape") closeNav();
|
||
});
|
||
window.addEventListener("popstate", () => setView(new URLSearchParams(location.search).get("view") || "overview", false));
|
||
loadAll();
|
||
})();
|