omkit 0.8.9 → 0.8.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,6 +22,8 @@ export declare class ExecutionTree {
22
22
  readonly store: LogStore;
23
23
  readonly folder: RunFolder;
24
24
  readonly root: ActionRun;
25
+ /** The om's name, as given to `om(name)`. */
26
+ readonly name: string;
25
27
  /**
26
28
  * The om's `.describe(…)` summary, carried here from the builder so it survives the
27
29
  * terminal `.run(...)` call. Stored so it is retrievable; nothing reads it yet.
@@ -122,6 +124,8 @@ export declare class ExecutionTree {
122
124
  * artifact name is free-form, so any printable separator can be forged inside a key.
123
125
  */
124
126
  private curatedArtifacts;
127
+ /** {@link curatedArtifacts}, each still carrying the node that registered it (for the report). */
128
+ private curatedArtifactsWithNode;
125
129
  /**
126
130
  * Absolute `.log` path for a node — root is `main.log`, children drop the `main/`
127
131
  * prefix. One directory level per ancestor, mirroring the tree.
@@ -13,6 +13,7 @@ import { LiveRenderer } from "../output/LiveRenderer.js";
13
13
  import { writeNodeLogs } from "../output/writers/NodeLogWriter.js";
14
14
  import { writeRollup } from "../output/writers/RollupWriter.js";
15
15
  import { writeResult } from "../output/writers/ResultWriter.js";
16
+ import { writeReport } from "../output/writers/ReportWriter.js";
16
17
  import { ActionRun } from "./ActionRun.js";
17
18
  import { procRegistry } from "../system/proc.js";
18
19
  import { currentNode } from "./context.js";
@@ -41,6 +42,8 @@ export class ExecutionTree {
41
42
  store = new LogStore();
42
43
  folder;
43
44
  root;
45
+ /** The om's name, as given to `om(name)`. */
46
+ name;
44
47
  /**
45
48
  * The om's `.describe(…)` summary, carried here from the builder so it survives the
46
49
  * terminal `.run(...)` call. Stored so it is retrievable; nothing reads it yet.
@@ -84,6 +87,7 @@ export class ExecutionTree {
84
87
  onUncaught = (e) => this.onFatal("uncaughtException", e);
85
88
  onUnhandled = (e) => this.onFatal("unhandledRejection", e);
86
89
  constructor(name, definedAt, description, mcp) {
90
+ this.name = name;
87
91
  this.description = description;
88
92
  this.mcp = mcp;
89
93
  // The run's identity: its name + where om() is written (not how it was launched),
@@ -307,7 +311,22 @@ export class ExecutionTree {
307
311
  await writeRollup(at("asserts.log"), flat, entries, (e) => e.level === "assert", assertSummary);
308
312
  await writeRollup(at("snapshots.log"), flat, entries, (e) => e.level === "snapshot");
309
313
  await writeRollup(at("artifacts.log"), flat, entries, (e) => e.level === "artifact");
310
- await writeResult(at("result.json"), this.runView());
314
+ const run = this.runView();
315
+ await writeResult(at("result.json"), run);
316
+ // The page is a convenience over the record above — a failure to build it must not
317
+ // cost the run its verdict, its exit code, or its other files.
318
+ try {
319
+ await writeReport(at("report.html"), {
320
+ name: this.name,
321
+ folder: this.folder.path(),
322
+ run,
323
+ entries,
324
+ artifacts: this.curatedArtifactsWithNode(),
325
+ });
326
+ }
327
+ catch (e) {
328
+ this.originalLog(`omkit: report.html not written — ${e instanceof Error ? e.message : String(e)}`);
329
+ }
311
330
  // Only now let go of the process hooks — a Ctrl+C during the writes must still be
312
331
  // caught (as a no-op) so it can't fall through to Node's kill-without-log default.
313
332
  process.off("SIGINT", this.onSigint);
@@ -338,6 +357,7 @@ export class ExecutionTree {
338
357
  ` asserts → ${at("asserts.log")} ${assertSummary}`,
339
358
  ` snapshots → ${at("snapshots.log")}`,
340
359
  ` artifacts → ${at("artifacts.log")}`,
360
+ ` report → ${at("report.html")}`,
341
361
  ];
342
362
  }
343
363
  /** The recap to the terminal (files are already written). */
@@ -412,6 +432,10 @@ export class ExecutionTree {
412
432
  * artifact name is free-form, so any printable separator can be forged inside a key.
413
433
  */
414
434
  curatedArtifacts() {
435
+ return this.curatedArtifactsWithNode().map(({ nodeId: _nodeId, ...view }) => view);
436
+ }
437
+ /** {@link curatedArtifacts}, each still carrying the node that registered it (for the report). */
438
+ curatedArtifactsWithNode() {
415
439
  const byKey = new Map();
416
440
  for (const { name, file, description, mime, nodeId } of this.artifactStore.all()) {
417
441
  byKey.set(`${nodeId}\0${name}`, {
@@ -419,6 +443,7 @@ export class ExecutionTree {
419
443
  file,
420
444
  mime,
421
445
  ...(description === undefined ? {} : { description }),
446
+ nodeId,
422
447
  });
423
448
  }
424
449
  return [...byKey.values()];
@@ -0,0 +1,16 @@
1
+ import type { LogEntry } from "../../foundation/LogEntry.ts";
2
+ import type { ArtifactView, RunView } from "./views.ts";
3
+ export interface ReportInput {
4
+ /** The om's name, as given to `om(name)`. */
5
+ readonly name: string;
6
+ /** The run folder (absolute). */
7
+ readonly folder: string;
8
+ readonly run: RunView;
9
+ readonly entries: readonly LogEntry[];
10
+ /** The curated artifacts, as in `run.artifacts`, with the node that registered each. */
11
+ readonly artifacts: ReadonlyArray<ArtifactView & {
12
+ readonly nodeId: string;
13
+ }>;
14
+ }
15
+ /** Writes `report.html` for a finished run. */
16
+ export declare function writeReport(file: string, input: ReportInput): Promise<void>;
@@ -0,0 +1,238 @@
1
+ import { readFile, readdir, stat, writeFile } from "node:fs/promises";
2
+ import { createRequire } from "node:module";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ /**
6
+ * `report.html` — the run as one self-contained page: the run view, every log entry, the
7
+ * snapshot payloads, the run's images (as base64), d3, and the viewer itself, all inlined so
8
+ * the file opens straight from disk, survives being zipped or attached, and needs no server.
9
+ *
10
+ * Heavy by design. Images are kept out of the main JSON — each sits in its own inert
11
+ * `<script type="text/plain">` block the viewer decodes only when it shows that image — so the
12
+ * page's one `JSON.parse` stays proportional to the log, not to the screenshots. The viewer
13
+ * paints a loader first and swaps it out once the data is parsed.
14
+ */
15
+ /** Snapshot payloads larger than this are embedded as truncated text, not parsed JSON. */
16
+ const SNAPSHOT_CAP = 2 * 1024 * 1024;
17
+ /** A single image larger than this is listed but not embedded. */
18
+ const IMAGE_CAP = 25 * 1024 * 1024;
19
+ /** Images stop being embedded once this many bytes are in the page. */
20
+ const IMAGES_TOTAL_CAP = 200 * 1024 * 1024;
21
+ /** Text artifacts (logs, json, md, …) are embedded up to this size. */
22
+ const TEXT_CAP = 1024 * 1024;
23
+ const IMAGE_MIME = {
24
+ ".png": "image/png",
25
+ ".jpg": "image/jpeg",
26
+ ".jpeg": "image/jpeg",
27
+ ".gif": "image/gif",
28
+ ".webp": "image/webp",
29
+ ".svg": "image/svg+xml",
30
+ };
31
+ /** Writes `report.html` for a finished run. */
32
+ export async function writeReport(file, input) {
33
+ const assets = [];
34
+ const files = await collectFiles(input, assets);
35
+ const snapshots = await collectSnapshots(input);
36
+ const payload = {
37
+ version: 1,
38
+ name: input.name,
39
+ folder: input.folder,
40
+ generatedAt: Date.now(),
41
+ run: input.run,
42
+ entries: input.entries,
43
+ snapshots,
44
+ files,
45
+ };
46
+ const [d3, css, client] = await Promise.all([
47
+ readFile(d3Path(), "utf8"),
48
+ readFile(assetPath("report.css"), "utf8"),
49
+ readFile(assetPath("report.js"), "utf8"),
50
+ ]);
51
+ await writeFile(file, renderPage(input, payload, assets, { d3, css, client }), "utf8");
52
+ }
53
+ function renderPage(input, payload, assets, code) {
54
+ const title = `Run: ${input.name}`;
55
+ const blobs = assets
56
+ .map((a) => `<script type="text/plain" id="${a.id}" data-mime="${a.mime}">${a.data}</script>`)
57
+ .join("\n");
58
+ return `<!doctype html>
59
+ <html lang="en">
60
+ <head>
61
+ <meta charset="utf-8">
62
+ <meta name="viewport" content="width=device-width, initial-scale=1">
63
+ <title>${escapeHtml(title)}</title>
64
+ <style>${code.css}</style>
65
+ </head>
66
+ <body>
67
+ <div id="loader" role="status" aria-live="polite">
68
+ <div class="loader-card">
69
+ <div class="loader-spin"></div>
70
+ <div class="loader-title">${escapeHtml(title)}</div>
71
+ <div class="loader-step" id="loader-step">Loading the run record…</div>
72
+ </div>
73
+ </div>
74
+ <div id="app" hidden></div>
75
+ <script id="omkit-data" type="application/json">${inertJson(payload)}</script>
76
+ ${blobs}
77
+ <script>${inertScript(code.d3)}</script>
78
+ <script>${inertScript(code.client)}</script>
79
+ </body>
80
+ </html>
81
+ `;
82
+ }
83
+ async function collectFiles(input, assets) {
84
+ const out = [];
85
+ const seen = new Set();
86
+ let budget = IMAGES_TOTAL_CAP;
87
+ const artifactTs = artifactTimes(input.entries);
88
+ const add = async (abs, meta) => {
89
+ const key = path.resolve(abs).toLowerCase();
90
+ if (seen.has(key))
91
+ return;
92
+ seen.add(key);
93
+ const base = { ...meta, file: abs, rel: relTo(input.folder, abs) };
94
+ let size;
95
+ try {
96
+ size = (await stat(abs)).size;
97
+ }
98
+ catch {
99
+ out.push({ ...base, size: 0, omitted: "file not found" });
100
+ return;
101
+ }
102
+ if (meta.mime.startsWith("image/")) {
103
+ if (size > IMAGE_CAP) {
104
+ out.push({ ...base, size, omitted: `larger than ${mb(IMAGE_CAP)}` });
105
+ }
106
+ else if (size > budget) {
107
+ out.push({ ...base, size, omitted: `page image budget (${mb(IMAGES_TOTAL_CAP)}) spent` });
108
+ }
109
+ else {
110
+ budget -= size;
111
+ const id = `asset-${assets.length + 1}`;
112
+ assets.push({ id, mime: meta.mime, data: (await readFile(abs)).toString("base64") });
113
+ out.push({ ...base, size, asset: id });
114
+ }
115
+ }
116
+ else if (isText(meta.mime) && size <= TEXT_CAP) {
117
+ out.push({ ...base, size, text: await readFile(abs, "utf8") });
118
+ }
119
+ else {
120
+ out.push({ ...base, size, omitted: isText(meta.mime) ? `larger than ${mb(TEXT_CAP)}` : "binary" });
121
+ }
122
+ };
123
+ for (const a of input.artifacts) {
124
+ const ts = artifactTs.get(`${a.nodeId}\0${path.resolve(a.file)}`);
125
+ await add(a.file, {
126
+ name: a.name,
127
+ mime: a.mime,
128
+ curated: true,
129
+ nodeId: a.nodeId,
130
+ ...(ts === undefined ? {} : { ts }),
131
+ ...(a.description === undefined ? {} : { description: a.description }),
132
+ });
133
+ }
134
+ // Images the run left in its folder without labelling them still belong in the picture.
135
+ for (const abs of await walk(input.folder)) {
136
+ const mime = IMAGE_MIME[path.extname(abs).toLowerCase()];
137
+ if (mime)
138
+ await add(abs, { name: path.basename(abs), mime, curated: false });
139
+ }
140
+ return out;
141
+ }
142
+ async function collectSnapshots(input) {
143
+ const out = [];
144
+ let seq = 0;
145
+ for (const e of input.entries) {
146
+ if (e.level !== "snapshot")
147
+ continue;
148
+ const split = e.message.lastIndexOf(" → ");
149
+ if (split < 0)
150
+ continue;
151
+ const name = e.message.slice(0, split);
152
+ const abs = e.message.slice(split + 3);
153
+ const base = { seq: ++seq, name, nodeId: e.nodeId, ts: e.ts, file: abs, rel: relTo(input.folder, abs) };
154
+ try {
155
+ const raw = await readFile(abs, "utf8");
156
+ if (raw.length > SNAPSHOT_CAP) {
157
+ out.push({ ...base, text: raw.slice(0, SNAPSHOT_CAP), truncated: true });
158
+ }
159
+ else {
160
+ try {
161
+ out.push({ ...base, value: JSON.parse(raw) });
162
+ }
163
+ catch {
164
+ out.push({ ...base, text: raw });
165
+ }
166
+ }
167
+ }
168
+ catch {
169
+ out.push({ ...base, text: "(snapshot file not found)" });
170
+ }
171
+ }
172
+ return out;
173
+ }
174
+ /** `ts` of each `artifact` timeline line, keyed by `(nodeId, file)` — the latest wins. */
175
+ function artifactTimes(entries) {
176
+ const out = new Map();
177
+ for (const e of entries) {
178
+ if (e.level !== "artifact")
179
+ continue;
180
+ const split = e.message.lastIndexOf(" → ");
181
+ if (split >= 0)
182
+ out.set(`${e.nodeId}\0${path.resolve(e.message.slice(split + 3))}`, e.ts);
183
+ }
184
+ return out;
185
+ }
186
+ async function walk(dir, depth = 0) {
187
+ if (depth > 4)
188
+ return [];
189
+ let names;
190
+ try {
191
+ names = await readdir(dir, { withFileTypes: true });
192
+ }
193
+ catch {
194
+ return [];
195
+ }
196
+ const out = [];
197
+ for (const d of names) {
198
+ const abs = path.join(dir, d.name);
199
+ if (d.isDirectory())
200
+ out.push(...(await walk(abs, depth + 1)));
201
+ else if (d.isFile())
202
+ out.push(abs);
203
+ }
204
+ return out.sort();
205
+ }
206
+ function relTo(folder, abs) {
207
+ const rel = path.relative(folder, abs);
208
+ return rel.startsWith("..") || path.isAbsolute(rel) ? abs : rel.split(path.sep).join("/");
209
+ }
210
+ function isText(mime) {
211
+ return (mime.startsWith("text/") ||
212
+ mime === "application/json" ||
213
+ mime === "application/x-ndjson" ||
214
+ mime === "image/svg+xml");
215
+ }
216
+ function mb(bytes) {
217
+ return `${Math.round(bytes / 1024 / 1024)} MB`;
218
+ }
219
+ /** JSON safe to sit inside a `<script>` block: no `</script>`, no `<!--`. */
220
+ function inertJson(value) {
221
+ return JSON.stringify(value).replace(/</g, "\\u003c");
222
+ }
223
+ /** JS safe to sit inside a `<script>` block. */
224
+ function inertScript(code) {
225
+ return code.replace(/<\/script/gi, "<\\/script");
226
+ }
227
+ function escapeHtml(s) {
228
+ return s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
229
+ }
230
+ /** The viewer's own files, next to this module (`report/`) in both `src` and `dist`. */
231
+ function assetPath(name) {
232
+ return fileURLToPath(new URL(`./report/${name}`, import.meta.url));
233
+ }
234
+ /** d3's UMD bundle — the package's `exports` hide `dist/`, so go via its resolved entry. */
235
+ function d3Path() {
236
+ const entry = createRequire(import.meta.url).resolve("d3");
237
+ return path.join(path.dirname(entry), "..", "dist", "d3.min.js");
238
+ }
@@ -0,0 +1,272 @@
1
+ :root {
2
+ --bg: #0b1020;
3
+ --panel: #111831;
4
+ --panel-2: #0e152b;
5
+ --raise: #18213f;
6
+ --line: #222c4d;
7
+ --line-soft: #1a2342;
8
+ --text: #e6eaf5;
9
+ --text-2: #a9b2cc;
10
+ --text-3: #6f7a99;
11
+ --accent: #3b82f6;
12
+ --accent-soft: rgba(59, 130, 246, 0.18);
13
+ --ok: #22c55e;
14
+ --fail: #ef4444;
15
+ --cancel: #f59e0b;
16
+ --running: #38bdf8;
17
+ --c1: #4f8ff7;
18
+ --c2: #8b5cf6;
19
+ --c3: #22c55e;
20
+ --c4: #f59e0b;
21
+ --c5: #f0568c;
22
+ --c6: #22d3ee;
23
+ --c7: #a3e635;
24
+ --c8: #fb7185;
25
+ --bar-text: #0b1020;
26
+ --shadow: 0 10px 30px rgba(0, 0, 0, 0.45);
27
+ --mono: ui-monospace, "SF Mono", "Cascadia Code", Consolas, monospace;
28
+ --sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
29
+ color-scheme: dark;
30
+ }
31
+ :root[data-theme="light"] {
32
+ --bg: #f4f6fb;
33
+ --panel: #ffffff;
34
+ --panel-2: #f8f9fc;
35
+ --raise: #eef1f8;
36
+ --line: #dde2ee;
37
+ --line-soft: #e9ecf4;
38
+ --text: #182033;
39
+ --text-2: #4b5672;
40
+ --text-3: #8590ab;
41
+ --accent: #2563eb;
42
+ --accent-soft: rgba(37, 99, 235, 0.12);
43
+ --bar-text: #0b1020;
44
+ --shadow: 0 10px 30px rgba(20, 30, 60, 0.15);
45
+ color-scheme: light;
46
+ }
47
+ @media (prefers-color-scheme: light) {
48
+ :root:not([data-theme="dark"]) {
49
+ --bg: #f4f6fb;
50
+ --panel: #ffffff;
51
+ --panel-2: #f8f9fc;
52
+ --raise: #eef1f8;
53
+ --line: #dde2ee;
54
+ --line-soft: #e9ecf4;
55
+ --text: #182033;
56
+ --text-2: #4b5672;
57
+ --text-3: #8590ab;
58
+ --accent: #2563eb;
59
+ --accent-soft: rgba(37, 99, 235, 0.12);
60
+ --shadow: 0 10px 30px rgba(20, 30, 60, 0.15);
61
+ color-scheme: light;
62
+ }
63
+ }
64
+
65
+ * { box-sizing: border-box; }
66
+ html, body { margin: 0; height: 100%; }
67
+ body {
68
+ background: var(--bg);
69
+ color: var(--text);
70
+ font: 13px/1.4 var(--sans);
71
+ overflow: hidden;
72
+ }
73
+ button { font: inherit; color: inherit; }
74
+ [hidden] { display: none !important; }
75
+
76
+ /* ── loader ─────────────────────────────────────────────── */
77
+ #loader {
78
+ position: fixed; inset: 0; display: grid; place-items: center;
79
+ background: var(--bg); z-index: 100;
80
+ }
81
+ .loader-card { display: grid; justify-items: center; gap: 14px; }
82
+ .loader-spin {
83
+ width: 42px; height: 42px; border-radius: 50%;
84
+ border: 3px solid var(--line); border-top-color: var(--accent);
85
+ animation: spin 0.9s linear infinite;
86
+ }
87
+ .loader-title { font-size: 16px; font-weight: 600; }
88
+ .loader-step { color: var(--text-3); font-size: 12px; }
89
+ @keyframes spin { to { transform: rotate(360deg); } }
90
+
91
+ /* ── shell ──────────────────────────────────────────────── */
92
+ #app {
93
+ height: 100vh; display: grid; gap: 10px; padding: 12px 14px 14px;
94
+ grid-template-rows: auto minmax(0, 1fr) 230px;
95
+ }
96
+ .top { display: flex; align-items: center; gap: 14px; min-width: 0; }
97
+ .top .titles { min-width: 0; flex: 1; }
98
+ .top h1 { margin: 0; font-size: 19px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
99
+ .top .meta { color: var(--text-2); display: flex; gap: 10px; align-items: center; margin-top: 3px; flex-wrap: wrap; }
100
+ .top .meta .sep { color: var(--text-3); }
101
+ .dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; flex: none; }
102
+ .dot.ok { background: var(--ok); }
103
+ .dot.failed { background: var(--fail); }
104
+ .dot.cancelled { background: var(--cancel); }
105
+ .dot.running { background: var(--running); }
106
+
107
+ .btn-group { display: flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; background: var(--panel); }
108
+ .btn {
109
+ border: 0; background: transparent; padding: 7px 12px; cursor: pointer;
110
+ display: inline-flex; align-items: center; gap: 7px; color: var(--text-2); white-space: nowrap;
111
+ }
112
+ .btn + .btn { border-left: 1px solid var(--line); }
113
+ .btn:hover { background: var(--raise); color: var(--text); }
114
+ .btn.active { background: var(--accent); color: #fff; }
115
+ .btn svg { width: 15px; height: 15px; }
116
+ .btn.solo { border: 1px solid var(--line); border-radius: 8px; background: var(--panel); }
117
+
118
+ .main {
119
+ display: grid; gap: 10px; min-height: 0;
120
+ grid-template-columns: minmax(0, 1fr) 320px;
121
+ }
122
+ .main.no-details { grid-template-columns: minmax(0, 1fr); }
123
+ .card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; min-height: 0; min-width: 0; }
124
+
125
+ /* ── timeline: tree + chart share one vertical scroll ──── */
126
+ .timeline { display: grid; grid-template-rows: 34px minmax(0, 1fr); overflow: hidden; }
127
+ .tl-head { display: grid; grid-template-columns: var(--tree-w, 420px) minmax(0, 1fr); border-bottom: 1px solid var(--line); }
128
+ .tl-head .cols { display: grid; grid-template-columns: minmax(0, 1fr) 84px 82px; align-items: center; border-right: 1px solid var(--line); }
129
+ .tl-head .cols > div { padding: 0 12px; color: var(--text-2); font-weight: 600; font-size: 12px; }
130
+ .tl-head .cols > div + div { border-left: 1px solid var(--line-soft); height: 100%; display: flex; align-items: center; }
131
+ .tl-head svg { display: block; width: 100%; height: 34px; }
132
+ .tl-body { overflow: auto; display: grid; grid-template-columns: var(--tree-w, 420px) minmax(0, 1fr); align-items: start; position: relative; }
133
+ .timeline.no-tree .tl-head, .timeline.no-tree .tl-body { grid-template-columns: minmax(0, 1fr); }
134
+ .timeline.no-tree .tree, .timeline.no-tree .tl-head .cols { display: none; }
135
+
136
+ .tree { border-right: 1px solid var(--line); position: sticky; left: 0; background: var(--panel); z-index: 2; }
137
+ .row {
138
+ height: var(--row-h); display: grid; grid-template-columns: minmax(0, 1fr) 84px 82px;
139
+ align-items: center; cursor: pointer; border-bottom: 1px solid transparent;
140
+ }
141
+ .row:hover { background: var(--raise); }
142
+ .row.selected { background: var(--accent-soft); }
143
+ .row .name { display: flex; align-items: center; gap: 7px; min-width: 0; padding-right: 8px; }
144
+ .row .name .label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
145
+ .row .name .tagchip { color: var(--text-3); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
146
+ .row .twist { width: 16px; height: 16px; flex: none; display: grid; place-items: center; color: var(--text-3); border: 0; background: none; padding: 0; cursor: pointer; }
147
+ .row .twist svg { width: 12px; height: 12px; transition: transform 0.12s; }
148
+ .row .twist.open svg { transform: rotate(90deg); }
149
+ .row .icon { width: 22px; height: 22px; flex: none; display: grid; place-items: center; border-radius: 6px; }
150
+ .row .icon svg { width: 14px; height: 14px; }
151
+ .row .status, .row .dur { padding: 0 12px; display: flex; align-items: center; gap: 7px; color: var(--text-2); font-variant-numeric: tabular-nums; }
152
+ .guide { width: 14px; flex: none; align-self: stretch; position: relative; }
153
+ .guide::before { content: ""; position: absolute; left: 7px; top: 0; bottom: 0; border-left: 1px solid var(--line); }
154
+
155
+ .chart { position: relative; min-width: 0; }
156
+ .chart svg { display: block; }
157
+ .chart .bar-label { font-size: 12px; fill: var(--bar-text); font-weight: 500; pointer-events: none; }
158
+ .chart .bar-label.outside { fill: var(--text); }
159
+ .chart .mark-label { font-size: 11px; fill: var(--text-2); pointer-events: none; }
160
+ .chart .grid line { stroke: var(--line-soft); }
161
+ .chart .cursor { stroke: var(--text-2); stroke-dasharray: 3 3; pointer-events: none; }
162
+ .chart .link { fill: none; stroke: var(--text-3); stroke-width: 1.2; }
163
+ .chart .bar { cursor: pointer; }
164
+ .chart .bar.selected { stroke: var(--text); stroke-width: 2; }
165
+ .chart .mark { cursor: pointer; stroke: var(--panel); stroke-width: 1.5; }
166
+ .axis text { fill: var(--text-2); font-size: 11px; }
167
+ .axis line, .axis path { stroke: var(--line); }
168
+
169
+ .flow-view, .flame-view { position: absolute; inset: 0; }
170
+ .flow-view svg, .flame-view svg { width: 100%; height: 100%; display: block; cursor: grab; }
171
+ .flow-card rect.box { fill: var(--raise); stroke: var(--line); rx: 8; }
172
+ .flow-card.selected rect.box { stroke: var(--accent); stroke-width: 2; }
173
+ .flow-card text { fill: var(--text); font-size: 12px; }
174
+ .flow-card text.sub { fill: var(--text-3); font-size: 11px; }
175
+ .flow-link { fill: none; stroke: var(--text-3); stroke-width: 1.3; }
176
+ .flame-rect { cursor: pointer; stroke: var(--panel); stroke-width: 1; }
177
+ .flame-rect.selected { stroke: var(--text); stroke-width: 2; }
178
+ .flame-text { font-size: 11px; fill: var(--bar-text); pointer-events: none; }
179
+
180
+ /* ── details ────────────────────────────────────────────── */
181
+ .details { display: grid; grid-template-rows: auto auto minmax(0, 1fr); overflow: hidden; }
182
+ .details .dh { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; font-weight: 600; }
183
+ .details .x { border: 0; background: none; cursor: pointer; color: var(--text-2); padding: 2px; }
184
+ .details .who { display: flex; align-items: center; gap: 10px; padding: 4px 14px 10px; }
185
+ .details .who .icon { width: 30px; height: 30px; border-radius: 8px; display: grid; place-items: center; flex: none; }
186
+ .details .who .icon svg { width: 16px; height: 16px; }
187
+ .details .who .nm { font-size: 16px; font-weight: 600; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
188
+ .pill { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: 999px; background: var(--raise); font-size: 12px; }
189
+ .details .scroll { overflow: auto; padding: 0 14px 14px; }
190
+ .kv { display: grid; grid-template-columns: 92px minmax(0, 1fr); gap: 6px 10px; margin: 2px 0 12px; }
191
+ .kv .k { color: var(--text-2); }
192
+ .kv .v { min-width: 0; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
193
+ a { color: var(--accent); text-decoration: none; }
194
+ a:hover { text-decoration: underline; }
195
+ .tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--line); margin: 0 0 10px; overflow-x: auto; }
196
+ .tab { border: 0; background: none; padding: 7px 9px; cursor: pointer; color: var(--text-2); border-bottom: 2px solid transparent; white-space: nowrap; }
197
+ .tab.active { color: var(--text); border-bottom-color: var(--accent); font-weight: 600; }
198
+ .section-title { font-weight: 600; margin: 14px 0 8px; }
199
+ .ev-list { list-style: none; margin: 0; padding: 0; position: relative; }
200
+ .ev-list li { display: grid; grid-template-columns: 64px 18px minmax(0, 1fr); gap: 8px; align-items: start; padding: 4px 0; }
201
+ .ev-list .t { color: var(--text-2); font-variant-numeric: tabular-nums; text-align: right; }
202
+ .ev-list .m { overflow-wrap: anywhere; }
203
+ .ev-ico { width: 16px; height: 16px; border-radius: 50%; display: grid; place-items: center; margin-top: 1px; }
204
+ .ev-ico svg { width: 10px; height: 10px; }
205
+ pre.code {
206
+ margin: 0; padding: 10px; background: var(--panel-2); border: 1px solid var(--line-soft); border-radius: 8px;
207
+ font: 12px/1.45 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; max-height: 60vh; overflow: auto;
208
+ }
209
+ .muted { color: var(--text-3); }
210
+ .thumbs { display: grid; grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); gap: 8px; }
211
+ .thumb { border: 1px solid var(--line); border-radius: 6px; overflow: hidden; cursor: pointer; background: var(--panel-2); aspect-ratio: 16 / 10; display: grid; place-items: center; }
212
+ .thumb:hover { border-color: var(--accent); }
213
+ .thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
214
+ .thumb .json-ico { font: 600 12px var(--mono); color: var(--text-3); }
215
+
216
+ /* ── bottom strip ───────────────────────────────────────── */
217
+ .bottom { display: grid; gap: 10px; grid-template-columns: minmax(0, 1fr) 270px 250px; min-height: 0; }
218
+ .lower { display: grid; grid-template-rows: auto minmax(0, 1fr); overflow: hidden; }
219
+ .lower .tabs { margin: 0; padding: 0 10px; align-items: center; }
220
+ .lower .tabs .spacer { flex: 1; }
221
+ .filter { border: 1px solid var(--line); background: var(--panel-2); border-radius: 7px; padding: 5px 9px; color: var(--text); width: 200px; margin: 4px 0; }
222
+ .lower .body { overflow: auto; padding: 10px 12px; }
223
+ .gallery { display: flex; gap: 12px; }
224
+ .gal-item { flex: none; width: 160px; cursor: pointer; border: 1px solid transparent; border-radius: 8px; padding: 4px; }
225
+ .gal-item:hover, .gal-item.active { border-color: var(--accent); background: var(--accent-soft); }
226
+ .gal-item .thumb { aspect-ratio: 16 / 10; }
227
+ .gal-item .cap { display: flex; gap: 8px; margin-top: 5px; font-size: 12px; }
228
+ .gal-item .cap .t { color: var(--text-2); font-variant-numeric: tabular-nums; }
229
+ .gal-item .cap .n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
230
+ .gal-item .sz { color: var(--text-3); font-size: 11px; }
231
+ table.list { width: 100%; border-collapse: collapse; font-size: 12px; }
232
+ table.list td { padding: 3px 8px; border-bottom: 1px solid var(--line-soft); vertical-align: top; }
233
+ table.list td.t { color: var(--text-2); white-space: nowrap; font-variant-numeric: tabular-nums; width: 1%; }
234
+ table.list td.p { color: var(--text-3); white-space: nowrap; max-width: 260px; overflow: hidden; text-overflow: ellipsis; width: 1%; }
235
+ table.list td.m { font-family: var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; }
236
+ table.list tr.err td.m { color: var(--fail); }
237
+ table.list tr { cursor: pointer; }
238
+ table.list tr:hover td { background: var(--raise); }
239
+ .legend { padding: 10px 14px; }
240
+ .legend h3, .overview h3 { margin: 0 0 10px; font-size: 13px; font-weight: 600; }
241
+ .legend .grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 9px 12px; }
242
+ .legend .li { display: flex; align-items: center; gap: 8px; color: var(--text-2); font-size: 12px; }
243
+ .overview { padding: 10px 14px; display: grid; grid-template-rows: auto auto minmax(0, 1fr); }
244
+ .overview .range { color: var(--text-2); font-size: 11px; text-align: right; margin-bottom: 4px; font-variant-numeric: tabular-nums; }
245
+ .overview svg { width: 100%; height: 100%; display: block; }
246
+ .overview .brush .selection { fill: var(--accent-soft); stroke: var(--text-2); }
247
+
248
+ /* ── floating ───────────────────────────────────────────── */
249
+ .tip {
250
+ position: fixed; pointer-events: none; z-index: 50; background: var(--panel); border: 1px solid var(--line);
251
+ border-radius: 8px; padding: 8px 10px; box-shadow: var(--shadow); max-width: 360px; font-size: 12px;
252
+ }
253
+ .tip .tt { font-weight: 600; margin-bottom: 2px; }
254
+ .tip .ts { color: var(--text-2); }
255
+ .tip img { display: block; max-width: 240px; max-height: 150px; border-radius: 4px; margin: 6px 0; border: 1px solid var(--line); }
256
+ .modal { position: fixed; inset: 0; background: rgba(5, 8, 18, 0.72); z-index: 60; display: grid; place-items: center; padding: 28px; }
257
+ .modal .box { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; box-shadow: var(--shadow); max-width: min(1200px, 100%); max-height: 100%; display: grid; grid-template-rows: auto minmax(0, 1fr); overflow: hidden; }
258
+ .modal .mh { display: flex; align-items: center; gap: 12px; padding: 10px 14px; border-bottom: 1px solid var(--line); }
259
+ .modal .mh .nm { font-weight: 600; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
260
+ .modal .mb { overflow: auto; padding: 14px; }
261
+ .modal img { max-width: 100%; display: block; margin: auto; }
262
+ .menu { position: fixed; z-index: 55; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; box-shadow: var(--shadow); padding: 4px; min-width: 200px; }
263
+ .menu button { display: block; width: 100%; text-align: left; border: 0; background: none; padding: 7px 10px; border-radius: 6px; cursor: pointer; }
264
+ .menu button:hover { background: var(--raise); }
265
+ .toast { position: fixed; bottom: 18px; left: 50%; transform: translateX(-50%); background: var(--raise); border: 1px solid var(--line); padding: 7px 14px; border-radius: 8px; z-index: 70; }
266
+
267
+ @media (max-width: 1100px) {
268
+ .bottom { grid-template-columns: minmax(0, 1fr); }
269
+ .legend, .overview { display: none; }
270
+ .main { grid-template-columns: minmax(0, 1fr); }
271
+ .details { display: none; }
272
+ }