Add browser dashboard, run tracing, and logged navigation decisions

- dashboard.py: stdlib http.server + JSON API (tree/trace/dream/status,
  POST submit/relabel); static/ single-page Redux-DevTools-style inspector
- trace.py: runs.jsonl lifecycle events keyed by run_id
- scheduler/skills/skill: every decision carries run_id; navigation choices
  now logged (navigate:category/leaf); requeues stamp meta.parent_run
- cli: 'dashboard' subcommand; config.json untracked per-machine (see
  config.example.json)
This commit is contained in:
Denton Social
2026-09-23 13:43:39 -05:00
parent b568e6bd48
commit 0db41241be
15 changed files with 1581 additions and 16 deletions
+550
View File
@@ -0,0 +1,550 @@
"use strict";
const $ = (sel) => document.querySelector(sel);
const state = {
runs: [],
tree: { categories: {} },
status: { current: null, queue: [], tau: 0.6 },
dream: {},
selectedRunId: null,
selectedDecisionId: null,
phaseFilter: "",
scrub: 0,
flowSteps: [],
};
async function getJSON(url, opts) {
const res = await fetch(url, opts);
if (!res.ok) throw new Error(`${url}: ${res.status}`);
return res.json();
}
async function refreshAll() {
const [trace, tree, status, dream] = await Promise.all([
getJSON("/api/trace"),
getJSON("/api/tree"),
getJSON("/api/status"),
getJSON("/api/dream"),
]);
state.runs = trace.runs;
state.tree = tree;
state.status = status;
state.dream = dream;
if (state.selectedRunId && !state.runs.some((r) => r.run_id === state.selectedRunId)) {
state.selectedRunId = null;
state.selectedDecisionId = null;
}
render();
}
function flash(msg) {
$("#mode-badge").textContent = msg;
}
/* ---------------- phase helpers ---------------- */
function phaseClass(phase) {
if (phase.startsWith("navigate:")) return "navigate";
return phase;
}
function shortPhase(phase) {
return phase.replace("navigate:", "nav:");
}
const ALL_PHASES = ["gate", "choice", "score", "navigate:category", "navigate:leaf", "predict"];
function optionProbs(row) {
return (row.options || []).map((o) => ({
id: o.id,
description: o.description,
p: (row.predicted_probs || {})[o.id] || 0,
}));
}
/* ---------------- timeline ---------------- */
function renderTimeline() {
const el = $("#timeline");
el.innerHTML = "";
for (const run of state.runs) {
const decisions = run.decisions.filter(
(d) => !state.phaseFilter || d.phase === state.phaseFilter
);
if (state.phaseFilter && decisions.length === 0) continue;
const group = document.createElement("div");
group.className = "run-group";
const head = document.createElement("div");
head.className = "run-head";
head.textContent = run.decisions[0] ? run.decisions[0].state.slice(0, 60) : run.run_id;
head.title = run.run_id;
head.addEventListener("click", () => {
state.selectedRunId = run.run_id;
state.selectedDecisionId = null;
state.scrub = 0;
render();
});
group.appendChild(head);
if (state.selectedRunId === run.run_id) {
for (const evt of run.events) {
group.appendChild(eventRow(evt));
}
for (const d of decisions) {
group.appendChild(decisionRow(d));
}
} else {
group.appendChild(dimRow(`${decisions.length} decisions`));
}
el.appendChild(group);
}
}
function dimRow(text) {
const div = document.createElement("div");
div.className = "trow event";
div.textContent = text;
return div;
}
function eventRow(evt) {
const div = document.createElement("div");
div.className = "trow event";
div.textContent = evt.kind;
if (evt.kind === "assessed") {
div.textContent = `assessed → ${evt.success ? "ok" : "fail"}`;
div.title = evt.summary || "";
} else if (evt.kind === "queued") {
div.textContent = `queued (${evt.label})`;
} else if (evt.kind === "preempted") {
div.textContent = `preempted ${evt.preempted}`;
}
return div;
}
function decisionRow(d) {
const div = document.createElement("div");
div.className = "trow" + (d.id === state.selectedDecisionId ? " selected" : "");
div.appendChild(pill(d.phase));
if (d.label_source === "human") div.appendChild(star());
const label = document.createElement("span");
label.textContent = `${d.selected} ${(d.predicted_probs[d.selected] || 0).toFixed(2)}`;
div.appendChild(label);
if (d.cost && !d.cost.correct) div.appendChild(mark("x"));
div.title = d.question;
div.addEventListener("click", () => {
state.selectedRunId = (d.extra || {}).run_id;
state.selectedDecisionId = d.id;
render();
});
return div;
}
function pill(text) {
const span = document.createElement("span");
span.className = `pill ${phaseClass(text)}`;
span.textContent = shortPhase(text);
return span;
}
function star() {
const span = document.createElement("span");
span.className = "human-star";
span.textContent = "★";
return span;
}
function mark(kind) {
const span = document.createElement("span");
span.className = kind === "x" ? "x" : "chk";
span.textContent = kind === "x" ? "✗" : "✓";
return span;
}
/* ---------------- flow graph ---------------- */
function buildFlowSteps(run) {
const items = [];
for (const evt of run.events) items.push({ kind: "event", ts: evt.ts, data: evt });
for (const d of run.decisions) items.push({ kind: "decision", ts: d.ts, data: d });
items.sort((a, b) => a.ts - b.ts);
return items;
}
function renderFlow() {
const el = $("#flow");
const run = state.runs.find((r) => r.run_id === state.selectedRunId);
$("#run-label").textContent = run ? run.run_id : "";
$("#scrubber").max = 0;
$("#scrubber").value = 0;
state.flowSteps = [];
el.innerHTML = "";
if (!run) {
const empty = document.createElement("div");
empty.className = "muted";
empty.textContent = "select a run from the timeline";
el.appendChild(empty);
return;
}
const steps = buildFlowSteps(run);
state.flowSteps = steps;
$("#scrubber").max = Math.max(0, steps.length - 1);
$("#scrubber").value = 0;
for (let i = 0; i < steps.length; i++) {
if (i > 0) el.appendChild(edge(steps[i - 1].data));
const node = steps[i].kind === "event" ? eventNode(steps[i].data) : decisionNode(steps[i].data);
node.dataset.step = i;
if (i > state.scrub) node.classList.add("dim");
el.appendChild(node);
}
}
function edge(prev) {
const div = document.createElement("div");
div.className = "edge";
if (prev.kind === "decision") {
const p = (prev.predicted_probs || {})[prev.selected] || 0;
if (p >= 0.6) div.classList.add("hot");
}
return div;
}
function decisionNode(d) {
const node = document.createElement("div");
const ok = d.cost ? d.cost.correct : null;
node.className = "node" + (d.id === state.selectedDecisionId ? " selected" : "");
if (ok === true) node.classList.add("ok");
if (ok === false) node.classList.add("fail");
const head = document.createElement("div");
head.className = "node-head";
head.appendChild(pill(d.phase));
const q = document.createElement("div");
q.className = "node-question";
q.textContent = d.question;
head.appendChild(q);
if (d.label_source === "human") head.appendChild(star());
const id = document.createElement("div");
id.className = "node-id";
id.textContent = d.id;
head.appendChild(id);
node.appendChild(head);
for (const opt of optionProbs(d)) {
node.appendChild(optionRow(opt, d));
}
const meta = document.createElement("div");
meta.className = "node-meta";
const sel = document.createElement("span");
sel.textContent = `selected: ${d.selected}`;
meta.appendChild(sel);
if (d.label_source === "human") {
const obs = document.createElement("span");
obs.textContent = `human override → ${d.observed_outcome}`;
obs.style.color = "var(--human)";
meta.appendChild(obs);
}
if (d.cost) {
const nll = document.createElement("span");
nll.className = "cost-nll";
nll.textContent = `nll ${d.cost.nll.toFixed(3)} ×${d.cost.weight}`;
meta.appendChild(nll);
}
const ex = d.extra || {};
const timing = document.createElement("span");
timing.textContent = ex.total_seconds ? `${ex.total_seconds.toFixed(2)}s` : "";
meta.appendChild(timing);
node.appendChild(meta);
node.addEventListener("click", () => {
state.selectedDecisionId = d.id;
state.selectedRunId = (d.extra || {}).run_id;
render();
});
return node;
}
function optionRow(opt, d) {
const row = document.createElement("div");
const cls = ["opt-row"];
if (opt.id === d.selected) cls.push("selected");
row.className = cls.join(" ");
const label = document.createElement("span");
label.className = "opt-label";
label.textContent = opt.id;
label.title = opt.description;
row.appendChild(label);
const barTrack = document.createElement("span");
barTrack.className = "opt-bar-track";
const bar = document.createElement("span");
bar.className = "opt-bar";
bar.style.width = `${Math.max(opt.p * 100, 1)}%`;
barTrack.appendChild(bar);
row.appendChild(barTrack);
const marks = document.createElement("span");
marks.className = "opt-marks";
if (opt.id === d.observed_outcome && opt.id !== d.selected) marks.appendChild(mark("chk"));
row.appendChild(marks);
const pct = document.createElement("span");
pct.className = "opt-pct";
pct.textContent = `${(opt.p * 100).toFixed(0)}%`;
row.appendChild(pct);
return row;
}
function eventNode(evt) {
const node = document.createElement("div");
node.className = "node event-node";
if (evt.kind === "assessed") node.classList.add(evt.success ? "ok" : "fail");
const kind = document.createElement("div");
kind.className = "evt-kind";
kind.textContent = evt.kind;
node.appendChild(kind);
const body = document.createElement("div");
body.className = "node-question";
if (evt.kind === "assessed") {
body.textContent = evt.summary || "";
if (evt.updated_request) {
const req = document.createElement("div");
req.className = "muted";
req.textContent = `→ requeued: ${evt.updated_request}`;
node.appendChild(req);
}
} else if (evt.kind === "queued") {
body.textContent = `urgency ${evt.label} (weight ${Number(evt.weight || 0).toFixed(2)})`;
} else if (evt.kind === "preempted") {
body.textContent = `interrupted ${evt.preempted}, requeued with state`;
} else if (evt.kind === "dropped") {
body.textContent = evt.reason || "";
} else {
body.textContent = evt.summary || evt.text || "";
}
node.appendChild(body);
return node;
}
/* ---------------- inspector ---------------- */
function selectedDecision() {
if (!state.selectedDecisionId) return null;
for (const run of state.runs) {
for (const d of run.decisions) {
if (d.id === state.selectedDecisionId) return d;
}
}
return null;
}
function renderInspector() {
const el = $("#inspector");
const d = selectedDecision();
$("#relabel-btn").disabled = !d;
if (!d) {
el.innerHTML = '<div class="muted">click a decision node to inspect it</div>';
return;
}
el.innerHTML = "";
el.appendChild(kv("id", d.id));
el.appendChild(kv("phase", d.phase || "—"));
el.appendChild(kv("state", d.state));
const pre = document.createElement("pre");
pre.className = "json";
const copy = { ...d };
delete copy.cost;
pre.textContent = JSON.stringify(copy, null, 2);
el.appendChild(pre);
}
function kv(k, v) {
const div = document.createElement("div");
div.className = "kv";
div.innerHTML = `<span class="k">${k}:</span> `;
div.appendChild(document.createTextNode(v));
return div;
}
/* ---------------- skill tree ---------------- */
function renderTree() {
const el = $("#skill-tree");
el.innerHTML = "";
const cats = state.tree.categories || {};
for (const [cat, skills] of Object.entries(cats)) {
const div = document.createElement("div");
div.className = "cat";
const name = document.createElement("div");
name.className = "cat-name";
name.textContent = cat;
div.appendChild(name);
for (const skill of skills) {
const row = document.createElement("div");
row.className = "skill-row";
const nm = document.createElement("span");
nm.textContent = skill.name;
const desc = document.createElement("span");
desc.className = "sdesc";
desc.textContent = skill.description;
row.appendChild(nm);
row.appendChild(desc);
div.appendChild(row);
}
el.appendChild(div);
}
}
/* ---------------- status / dream ---------------- */
function renderStatus() {
const el = $("#status");
el.innerHTML = "";
const grid = document.createElement("div");
grid.className = "stat-grid";
const current = state.status.current
? `${state.status.current.skill} (${state.status.current.request_id})`
: "idle";
grid.appendChild(stat("current", current));
grid.appendChild(stat("queue", String(state.status.queue.length)));
grid.appendChild(stat("tau", String(state.status.tau)));
grid.appendChild(stat("rows", String(state.dream.rows)));
const acc = state.dream.accuracy;
const ce = state.dream.cross_entropy;
const ece = state.dream.ece;
grid.appendChild(stat("acc", acc == null ? "n/a" : acc.toFixed(3)));
grid.appendChild(stat("CE", ce == null ? "n/a" : ce.toFixed(4)));
grid.appendChild(stat("ECE", ece == null ? "n/a" : ece.toFixed(4)));
grid.appendChild(stat("human", String(state.dream.human_overrides)));
el.appendChild(grid);
const q = document.createElement("div");
q.className = "muted";
q.style.marginTop = "8px";
q.textContent = state.status.queue
.map((item) => `${item.id} w=${item.weight.toFixed(2)} ${item.text}`)
.join("\n") || "queue empty";
el.appendChild(q);
}
function stat(k, v) {
const div = document.createElement("div");
div.className = "stat";
div.innerHTML = `<span class="muted">${k}</span><br><span class="v">${v}</span>`;
return div;
}
/* ---------------- scrubber ---------------- */
function onScrub() {
state.scrub = Number($("#scrubber").value);
document.querySelectorAll("#flow .node").forEach((node) => {
node.classList.toggle("dim", Number(node.dataset.step) > state.scrub);
});
}
/* ---------------- relabel modal ---------------- */
function openRelabel() {
const d = selectedDecision();
if (!d) return;
$("#relabel-id").textContent = `${d.id}${d.question}`;
const sel = $("#relabel-options");
sel.innerHTML = "";
for (const o of d.options) {
const opt = document.createElement("option");
opt.value = o.id;
opt.textContent = `${o.id}${o.description}`;
sel.appendChild(opt);
}
sel.value = d.observed_outcome;
$("#relabel-modal").classList.remove("hidden");
}
async function applyRelabel() {
const d = selectedDecision();
if (!d) return;
const outcome = $("#relabel-options").value;
const res = await getJSON("/api/relabel", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: d.id, outcome }),
});
$("#relabel-modal").classList.add("hidden");
if (res.ok) {
flash(`relabeled ${d.id}${outcome}`);
await refreshAll();
} else {
flash("relabel failed");
}
}
/* ---------------- wiring ---------------- */
$("#submit-form").addEventListener("submit", async (e) => {
e.preventDefault();
const text = $("#query-input").value.trim();
if (!text) return;
try {
const res = await getJSON("/api/submit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
});
flash(`[${res.status}] ${res.detail}`);
} catch (err) {
flash(`submit failed: ${err.message}`);
}
$("#query-input").value = "";
await refreshAll();
});
$("#refresh-btn").addEventListener("click", async () => {
try {
await refreshAll();
} catch (err) {
flash(`refresh failed: ${err.message}`);
}
});
$("#phase-filter").addEventListener("change", (e) => {
state.phaseFilter = e.target.value;
render();
});
$("#scrubber").addEventListener("input", onScrub);
$("#relabel-btn").addEventListener("click", openRelabel);
$("#relabel-cancel").addEventListener("click", () => $("#relabel-modal").classList.add("hidden"));
$("#relabel-apply").addEventListener("click", applyRelabel);
function render() {
renderTimeline();
renderFlow();
renderInspector();
renderTree();
renderStatus();
}
function init() {
const filter = $("#phase-filter");
for (const p of ALL_PHASES) {
const opt = document.createElement("option");
opt.value = p;
opt.textContent = p;
filter.appendChild(opt);
}
refreshAll().catch((err) => flash(`failed to load: ${err.message}`));
setInterval(() => refreshAll().catch(() => {}), 4000);
}
init();
+80
View File
@@ -0,0 +1,80 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SemIf Agent Dashboard</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header class="topbar">
<div class="brand">SemIf <span class="muted">agent dashboard</span></div>
<div id="mode-badge" class="badge"></div>
</header>
<section class="inputbar">
<form id="submit-form">
<input id="query-input" type="text" autocomplete="off" spellcheck="false"
placeholder="type a request, e.g. 'send my girlfriend an email that I'm running late'">
<button type="submit">submit</button>
</form>
<button id="refresh-btn" type="button" title="reload trace">refresh</button>
</section>
<main class="layout">
<aside class="panel left">
<div class="panel-head">
<h2>Timeline</h2>
<select id="phase-filter" title="filter by phase">
<option value="">all phases</option>
</select>
</div>
<div id="timeline" class="timeline"></div>
</aside>
<section class="panel center">
<div class="panel-head">
<h2>Decision flow</h2>
<span id="run-label" class="muted"></span>
<div class="spacer"></div>
<label class="scrub-label">time travel
<input id="scrubber" type="range" min="0" max="0" value="0" step="1">
</label>
</div>
<div id="flow" class="flow"></div>
</section>
<aside class="panel right">
<div class="panel-head">
<h2>Inspector</h2>
<button id="relabel-btn" type="button" class="small" disabled>relabel…</button>
</div>
<div id="inspector" class="inspector"></div>
<div class="panel-head">
<h2>Skill tree</h2>
</div>
<div id="skill-tree" class="skill-tree"></div>
<div class="panel-head">
<h2>Dream / status</h2>
</div>
<div id="status" class="status"></div>
</aside>
</main>
<div id="relabel-modal" class="modal hidden">
<div class="modal-box">
<h3>Relabel decision</h3>
<p class="muted" id="relabel-id"></p>
<label>observed outcome
<select id="relabel-options"></select>
</label>
<div class="modal-actions">
<button id="relabel-cancel" type="button">cancel</button>
<button id="relabel-apply" type="button" class="primary">apply (3x weight)</button>
</div>
</div>
</div>
<script src="/static/app.js"></script>
</body>
</html>
+346
View File
@@ -0,0 +1,346 @@
:root {
--bg: #14161b;
--panel: #1c1f27;
--panel-2: #232733;
--line: #2e3340;
--text: #d7dae0;
--muted: #8b93a3;
--accent: #4f8cff;
--ok: #3ecf8e;
--fail: #ff6b6b;
--warn: #ffc94d;
--human: #c792ea;
font-size: 14px;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
background: var(--bg);
color: var(--text);
font-family: "SF Mono", "Cascadia Code", "JetBrains Mono", Consolas, monospace;
}
.muted { color: var(--muted); }
.spacer { flex: 1; }
.topbar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
background: var(--panel);
border-bottom: 1px solid var(--line);
font-size: 15px;
font-weight: 700;
}
.badge {
padding: 2px 10px;
border: 1px solid var(--line);
border-radius: 12px;
color: var(--muted);
font-weight: 400;
font-size: 12px;
}
.inputbar {
display: flex;
gap: 8px;
padding: 10px 16px;
background: var(--panel-2);
border-bottom: 1px solid var(--line);
}
.inputbar form { display: flex; flex: 1; gap: 8px; }
#query-input {
flex: 1;
background: var(--bg);
color: var(--text);
border: 1px solid var(--line);
border-radius: 6px;
padding: 8px 12px;
outline: none;
}
#query-input:focus { border-color: var(--accent); }
button {
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--line);
border-radius: 6px;
padding: 8px 14px;
cursor: pointer;
}
button:hover { border-color: var(--accent); }
button:disabled { opacity: 0.45; cursor: default; }
button.small { padding: 4px 8px; font-size: 12px; }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.layout {
display: grid;
grid-template-columns: 300px 1fr 360px;
height: calc(100vh - 98px);
}
.panel {
overflow: hidden;
display: flex;
flex-direction: column;
border-right: 1px solid var(--line);
background: var(--panel);
}
.panel-head {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-bottom: 1px solid var(--line);
background: var(--panel-2);
}
.panel-head h2 {
margin: 0;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
select {
background: var(--bg);
color: var(--text);
border: 1px solid var(--line);
border-radius: 6px;
padding: 4px 6px;
font-size: 12px;
}
/* ---------- timeline ---------- */
.timeline {
overflow-y: auto;
flex: 1;
padding: 8px;
}
.timeline .run-group { margin-bottom: 10px; }
.timeline .run-head {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 5px 8px;
border-radius: 6px;
background: var(--panel-2);
font-size: 12px;
}
.timeline .run-head:hover { border-color: var(--accent); }
.timeline .trow {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-left: 2px solid var(--line);
margin-left: 10px;
cursor: pointer;
font-size: 12px;
}
.timeline .trow:hover { background: var(--panel-2); }
.timeline .trow.selected { background: var(--panel-2); border-left-color: var(--accent); }
.timeline .trow.event { color: var(--muted); cursor: default; }
.pill {
border-radius: 3px;
padding: 0 5px;
font-size: 10px;
text-transform: uppercase;
white-space: nowrap;
}
.pill.gate { background: #2b3a52; color: #9ec1ff; }
.pill.choice { background: #4a3a2b; color: #ffd59e; }
.pill.score { background: #3a2b4a; color: #d59eff; }
.pill.navigate { background: #2b4a3a; color: #9effd5; }
.pill.predict { background: #2b464a; color: #9eeaff; }
.pill.assess { background: #4a2b2b; color: #ff9e9e; }
.human-star { color: var(--human); }
/* ---------- flow graph ---------- */
.flow {
overflow-y: auto;
flex: 1;
padding: 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
}
.edge {
width: 2px;
height: 18px;
background: var(--line);
margin: 0 auto;
}
.edge.hot { background: var(--accent); }
.node {
width: 420px;
max-width: 100%;
background: var(--panel-2);
border: 1px solid var(--line);
border-radius: 8px;
padding: 8px 10px;
cursor: pointer;
}
.node:hover { border-color: var(--accent); }
.node.selected { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(79, 140, 255, 0.25); }
.node.dim { opacity: 0.35; }
.node.ok { border-left: 3px solid var(--ok); }
.node.fail { border-left: 3px solid var(--fail); }
.node-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.node-head .pill { font-size: 10px; }
.node-question {
flex: 1;
font-size: 12px;
font-weight: 600;
}
.node-question .q { color: var(--text); }
.node-id { font-size: 10px; color: var(--muted); }
.opt-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
margin: 3px 0;
position: relative;
}
.opt-row .opt-label { width: 130px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.opt-bar-track {
flex: 1;
height: 14px;
background: var(--bg);
border-radius: 3px;
overflow: hidden;
}
.opt-bar { height: 100%; background: var(--line); transition: width 0.2s; }
.opt-row.selected .opt-bar { background: var(--accent); }
.opt-row.selected .opt-label { color: var(--text); font-weight: 700; }
.opt-row.observed .opt-bar { background: var(--human); }
.opt-row .opt-pct { width: 48px; text-align: right; color: var(--muted); }
.opt-row.selected .opt-pct { color: var(--accent); }
.opt-marks { position: absolute; right: 102px; }
.opt-row .chk { color: var(--ok); }
.opt-row .x { color: var(--fail); }
.node-meta {
margin-top: 6px;
font-size: 10px;
color: var(--muted);
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.cost-nll { color: var(--warn); }
.node.event-node {
width: 360px;
background: var(--bg);
cursor: default;
}
.node.event-node .evt-kind { text-transform: uppercase; color: var(--muted); font-size: 10px; }
/* ---------- inspector / tree / status ---------- */
.right { border-right: none; }
.inspector, .status { overflow-y: auto; padding: 10px; font-size: 11px; }
.skill-tree { overflow-y: auto; padding: 10px; font-size: 12px; flex: 1; }
pre.json {
margin: 0;
white-space: pre-wrap;
word-break: break-all;
color: var(--text);
}
.kv { margin: 2px 0; }
.kv .k { color: var(--muted); }
.cat { margin-bottom: 10px; }
.cat .cat-name { font-weight: 700; margin-bottom: 4px; }
.skill-row { display: flex; gap: 6px; align-items: baseline; padding-left: 8px; font-size: 11px; }
.skill-row .sdesc { color: var(--muted); }
.stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; }
.stat-grid .stat { background: var(--panel-2); border-radius: 4px; padding: 4px 6px; }
.stat-grid .stat .v { font-weight: 700; }
/* ---------- scrubber ---------- */
.scrub-label { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--muted); }
#scrubber { width: 180px; }
/* ---------- modal ---------- */
.modal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
}
.modal.hidden { display: none; }
.modal-box {
background: var(--panel-2);
border: 1px solid var(--line);
border-radius: 10px;
padding: 18px;
min-width: 320px;
display: flex;
flex-direction: column;
gap: 12px;
}
.modal-box h3 { margin: 0; }
.modal-box label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; }
.hidden { display: none !important; }