omkit 0.8.15 → 0.8.16

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.
@@ -4,15 +4,15 @@
4
4
  (function () {
5
5
  "use strict";
6
6
 
7
+ const TIP_TAGS = 6; // tag chips a hover tooltip shows before "+N"
8
+ const TREE_TAGS = 4; // …and a timeline tree row
7
9
  const ROW_H = 42;
8
10
  const BAR_Y = 6;
9
11
  const BAR_H = 20;
10
- const FLAME_ROW = 26;
11
12
 
12
13
  // ── icons (24×24, stroked) ───────────────────────────────
13
14
  const ICON = {
14
15
  timeline: "M3 17l5-5 4 4 8-8M3 21h18",
15
- flame: "M12 3c1 4 6 6 6 11a6 6 0 0 1-12 0c0-3 2-4 2-7 2 1 3 3 3 5 1-2 1-6 1-9z",
16
16
  flow: "M4 4h7v6H4zM13 14h7v6h-7zM7.5 10v4h9v0M16.5 14v-1",
17
17
  zoomIn: "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4M11 8v6M8 11h6",
18
18
  zoomOut: "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4M8 11h6",
@@ -43,7 +43,6 @@
43
43
  info: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM12 11v5M12 8h.01",
44
44
  locate: "M12 5a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM12 2v3M12 19v3M2 12h3M19 12h3M12 10.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3z",
45
45
  code: "M8 7l-5 5 5 5M16 7l5 5-5 5",
46
- ret: "M9 10l-4 4 4 4M5 14h11a4 4 0 0 0 4-4V5",
47
46
  external: "M14 4h6v6M20 4l-9 9M18 14v6H4V6h6",
48
47
  };
49
48
  const svgIcon = (name, extra) =>
@@ -78,10 +77,6 @@
78
77
  };
79
78
  const fmtRel = (ms) => `+${fmtDur(Math.max(0, ms)).replace(/^0ms$/, "0ms")}`;
80
79
  const pad2 = (n) => String(n).padStart(2, "0");
81
- const fmtClock = (ts) => {
82
- const d = new Date(ts);
83
- return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}.${String(d.getMilliseconds()).padStart(3, "0")}`;
84
- };
85
80
  const fmtDate = (ts) => {
86
81
  const d = new Date(ts);
87
82
  return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
@@ -311,7 +306,7 @@
311
306
  selected: model.root.id,
312
307
  collapsed: new Set(),
313
308
  zoom: d3.zoomIdentity,
314
- detailTab: "overview",
309
+ detailOpen: new Set(["errors"]), // details-panel sections left open
315
310
  errFilter: "all",
316
311
  errHl: false,
317
312
  errOpen: new Set(),
@@ -332,7 +327,6 @@
332
327
  body: app.querySelector(".tl-body"),
333
328
  tree: app.querySelector(".tree"),
334
329
  chart: app.querySelector(".chart"),
335
- flame: app.querySelector(".flame-view"),
336
330
  flow: app.querySelector(".flow-view"),
337
331
  details: app.querySelector(".details"),
338
332
  lower: app.querySelector(".lower"),
@@ -345,10 +339,9 @@
345
339
  tip.hidden = true;
346
340
  document.body.appendChild(tip);
347
341
 
348
- let W = 600; // chart pixel width (timeline + flame share the x mapping)
342
+ let W = 600; // chart pixel width
349
343
  let baseX = d3.scaleLinear();
350
344
  const xz = () => state.zoom.rescaleX(baseX);
351
- let syncing = false;
352
345
 
353
346
  // ── header controls ──
354
347
  app.querySelectorAll("[data-view]").forEach((b) =>
@@ -371,6 +364,18 @@
371
364
  info.addEventListener("mousemove", (ev) => moveTip(ev));
372
365
  info.addEventListener("mouseleave", hideTip);
373
366
  info.addEventListener("click", () => select(model.root.id));
367
+ const legendBtn = app.querySelector("#legend-btn");
368
+ const legend = app.querySelector("#legend");
369
+ const toggleLegend = (open) => {
370
+ legend.hidden = !open;
371
+ legendBtn.setAttribute("aria-expanded", String(open));
372
+ legendBtn.classList.toggle("active", open);
373
+ };
374
+ legendBtn.addEventListener("click", () => toggleLegend(legend.hidden));
375
+ document.addEventListener("mousedown", (e) => {
376
+ if (!legend.hidden && !legend.contains(e.target) && !legendBtn.contains(e.target)) toggleLegend(false);
377
+ });
378
+ document.addEventListener("keydown", (e) => e.key === "Escape" && !legend.hidden && toggleLegend(false));
374
379
 
375
380
  // ── zoom behaviours ──
376
381
  const chartZoom = d3
@@ -378,28 +383,13 @@
378
383
  .scaleExtent([1, 5000])
379
384
  .filter((e) => (e.type === "wheel" ? e.ctrlKey || e.metaKey : !e.button && e.type !== "dblclick"))
380
385
  .on("zoom", (e) => onZoom(e.transform));
381
- const flameZoom = d3
382
- .zoom()
383
- .scaleExtent([1, 5000])
384
- .filter((e) => (e.type === "wheel" ? e.ctrlKey || e.metaKey : !e.button && e.type !== "dblclick"))
385
- .on("zoom", (e) => onZoom(e.transform));
386
386
  const flowZoom = d3.zoom().scaleExtent([0.1, 4]).on("zoom", (e) => flowG && flowG.attr("transform", e.transform));
387
387
 
388
388
  function activeZoom() {
389
- return state.view === "flame"
390
- ? { z: flameZoom, sel: d3.select(el.flame).select("svg") }
391
- : { z: chartZoom, sel: d3.select(el.chart).select("svg") };
389
+ return { z: chartZoom, sel: d3.select(el.chart).select("svg") };
392
390
  }
393
391
  function onZoom(t) {
394
392
  state.zoom = t;
395
- if (!syncing) {
396
- syncing = true;
397
- // keep the other x-view's internal transform in step
398
- const other = state.view === "flame" ? d3.select(el.chart).select("svg") : d3.select(el.flame).select("svg");
399
- const oz = state.view === "flame" ? chartZoom : flameZoom;
400
- if (!other.empty()) other.call(oz.transform, t);
401
- syncing = false;
402
- }
403
393
  redrawX();
404
394
  }
405
395
  function zoomBy(k) {
@@ -427,19 +417,18 @@
427
417
  function measure() {
428
418
  const sb = el.body.offsetWidth - el.body.clientWidth;
429
419
  el.head.style.paddingRight = `${sb}px`;
430
- W = Math.max(200, state.view === "flame" ? el.flame.clientWidth : el.chart.clientWidth);
420
+ W = Math.max(200, el.chart.clientWidth);
431
421
  const span = model.span;
432
422
  baseX = d3.scaleLinear().domain([-span * 0.01, span * 1.015]).range([0, W]);
433
- for (const z of [chartZoom, flameZoom]) z.translateExtent([[0, -1e6], [W, 1e6]]).extent([[0, 0], [W, 1]]);
423
+ chartZoom.translateExtent([[0, -1e6], [W, 1e6]]).extent([[0, 0], [W, 1]]);
434
424
  }
435
425
 
436
426
  function setView(v) {
437
427
  state.view = v;
438
428
  app.querySelectorAll("[data-view]").forEach((b) => b.classList.toggle("active", b.dataset.view === v));
439
- el.timeline.classList.toggle("no-tree", v === "flame");
440
429
  el.body.hidden = v !== "timeline";
441
430
  el.head.hidden = v === "flow";
442
- el.flame.hidden = v !== "flame";
431
+ el.timeline.classList.toggle("is-flow", v === "flow");
443
432
  el.flow.hidden = v !== "flow";
444
433
  el.windowSel.disabled = v === "flow";
445
434
  renderAll();
@@ -479,17 +468,17 @@
479
468
  ? `<button class="twist ${open ? "open" : ""}" title="${open ? "Collapse" : "Expand"}">${svgIcon("chevron")}</button>`
480
469
  : '<span class="twist"></span>';
481
470
  const color = colorOf(n);
482
- const rest = (n.v.tags || []).filter((t) => t !== displayName(n));
483
- const tags = rest.length ? `<span class="tagchip">${esc(rest.join(" · "))}</span>` : "";
471
+ const tags = chipsHtml(n.v.tags, TREE_TAGS, 18);
484
472
  return `<div class="row ${state.selected === n.id ? "selected" : ""}" data-id="${esc(n.id)}" title="${esc(n.v.path)}">
485
473
  <div class="name" style="padding-left:${n.depth ? 4 : 10}px">${guides}${twist}
486
474
  <span class="icon" style="color:${color};background:${alpha(color, 0.16)}">${svgIcon(iconOf(n))}</span>
487
- <span class="label">${esc(displayName(n))}</span>${tags}${
488
- model.errorsBy.get(n) ? `<span class="errbadge" title="${model.errorsBy.get(n)} error${model.errorsBy.get(n) > 1 ? "s" : ""}">${model.errorsBy.get(n)}</span>` : ""
489
- }
475
+ <span class="lines">
476
+ <span class="l1"><span class="label">${labelHtml(n)}</span>${
477
+ model.errorsBy.get(n) ? `<span class="errbadge" title="${model.errorsBy.get(n)} error${model.errorsBy.get(n) > 1 ? "s" : ""}">${model.errorsBy.get(n)}</span>` : ""
478
+ }</span>
479
+ <span class="l2"><span class="st"><span class="dot ${n.status}"></span>${esc(n.status)}</span><span class="sep">·</span><span class="du">${fmtDur(n.duration)}</span>${tags}</span>
480
+ </span>
490
481
  </div>
491
- <div class="status"><span class="dot ${n.status}"></span>${esc(n.status)}</div>
492
- <div class="dur">${fmtDur(n.duration)}</div>
493
482
  </div>`;
494
483
  }
495
484
  function toggle(n) {
@@ -527,7 +516,6 @@
527
516
  updateWindowLabel(x);
528
517
  updateOverviewBrush(x);
529
518
  if (state.view === "timeline" && chartSvg) drawRows(x);
530
- if (state.view === "flame") drawFlame(x);
531
519
  }
532
520
 
533
521
  function drawRows(x) {
@@ -575,9 +563,6 @@
575
563
  const cb = r.append("g").attr("class", "code-btn src");
576
564
  cb.append("circle").attr("r", 9);
577
565
  cb.append("text").attr("text-anchor", "middle").attr("y", 3.5).text("<>");
578
- const rb = r.append("g").attr("class", "code-btn ret");
579
- rb.append("circle").attr("r", 9);
580
- rb.append("path").attr("d", ICON.ret).attr("transform", "translate(-6,-6) scale(0.5)");
581
566
  return r;
582
567
  })
583
568
  .attr("transform", (_n, i) => `translate(0,${i * ROW_H})`)
@@ -598,7 +583,7 @@
598
583
  const t = d3.select(this);
599
584
  const x0 = x(n.start);
600
585
  const w = x(n.end) - x0;
601
- const text = `${displayName(n)} (${fmtDur(n.duration)})`;
586
+ const text = `${boxLabel(n)} (${fmtDur(n.duration)})`;
602
587
  const need = text.length * 6.6 + 14;
603
588
  const inside = w >= need;
604
589
  const clipX = Math.max(x0, 0);
@@ -617,32 +602,17 @@
617
602
  // …and, for a block spanning the chart, inside it right after its label
618
603
  const cx =
619
604
  right + 8 <= W - 10 ? right + 8 : left - 8 >= 10 ? left - 8 : Math.min(W - 12, (flip ? x0 : lx + tw) + 14);
620
- // buttons stack away from the block: rightward when they sit after it, leftward before it
621
- const dir = right + 8 <= W - 10 || left - 8 < 10 ? 1 : -1;
622
- const src = hasCode(n);
623
- const row = d3.select(this.parentNode);
624
- row
605
+ d3.select(this.parentNode)
625
606
  .select("g.code-btn.src")
626
- .style("display", src ? null : "none")
607
+ .style("display", hasPopup(n) ? null : "none")
627
608
  .attr("transform", `translate(${cx},${BAR_Y + BAR_H / 2})`);
628
- row
629
- .select("g.code-btn.ret")
630
- .style("display", hasValue(n) ? null : "none")
631
- .attr("transform", `translate(${src ? cx + dir * 21 : cx},${BAR_Y + BAR_H / 2})`);
632
609
  });
633
610
  g.select("g.code-btn.src")
634
611
  .on("click", (ev, n) => {
635
612
  ev.stopPropagation();
636
613
  toggleCode(n, ev);
637
614
  })
638
- .on("mouseenter", (ev) => showTip(ev, "Show source"))
639
- .on("mouseleave", hideTip);
640
- g.select("g.code-btn.ret")
641
- .on("click", (ev, n) => {
642
- ev.stopPropagation();
643
- toggleCode(n, ev, "value", true);
644
- })
645
- .on("mouseenter", (ev, n) => showTip(ev, `Returned: <span class="mono">${esc(valuePreview(n, 80))}</span>`))
615
+ .on("mouseenter", (ev, n) => showTip(ev, popupTip(n)))
646
616
  .on("mouseleave", hideTip);
647
617
  g.selectAll("rect.hit, text.bar-label").call(bindNode);
648
618
 
@@ -721,7 +691,7 @@
721
691
  function updateAxis(x) {
722
692
  const svg = d3.select(el.axis);
723
693
  if (state.view === "flow") return;
724
- const w = state.view === "flame" ? W : el.chart.clientWidth || W;
694
+ const w = el.chart.clientWidth || W;
725
695
  svg.attr("viewBox", null);
726
696
  const g = svg.selectAll("g.axis").data([0]).join("g").attr("class", "axis").attr("transform", "translate(0,33)");
727
697
  g.call(d3.axisTop(x).ticks(Math.max(2, Math.floor(w / 90))).tickFormat(fmtTick).tickSizeOuter(0));
@@ -729,9 +699,8 @@
729
699
  svg.selectAll("text.cursor-t").data([0]).join("text").attr("class", "cursor-t").attr("y", 12).attr("fill", css("--accent")).attr("font-size", 11);
730
700
  }
731
701
  function moveCursor(px) {
732
- const svgs = [chartSvg, flameSvg].filter(Boolean);
733
- for (const s of svgs) {
734
- s.select("line.cursor").attr("visibility", px == null ? "hidden" : "visible").attr("x1", px).attr("x2", px);
702
+ if (chartSvg) {
703
+ chartSvg.select("line.cursor").attr("visibility", px == null ? "hidden" : "visible").attr("x1", px).attr("x2", px);
735
704
  }
736
705
  const label = d3.select(el.axis).select("text.cursor-t");
737
706
  if (px == null) label.text("");
@@ -748,91 +717,6 @@
748
717
  el.windowSel.value = "0";
749
718
  }
750
719
 
751
- // ── flame view ──
752
- let flameSvg = null;
753
- /**
754
- * A row per node, packed: a node goes on the first row below its parent's that nothing
755
- * already placed there overlaps in time. Plain depth rows hid every step that ran beside a
756
- * long-lived sibling (a daemon, a server) — they shared a row and were drawn over.
757
- */
758
- let flameRows = null;
759
- function packFlame() {
760
- const rows = []; // rows[r] = [[start, end], …] already taken
761
- const out = new Map();
762
- const free = (r, a, b) => !(rows[r] || []).some(([s, e]) => a < e && s < b);
763
- (function place(n, parentRow) {
764
- let r = parentRow + 1;
765
- // a zero-length node still needs a sliver of time to claim, or it would stack on anything
766
- const a = n.start;
767
- const b = Math.max(n.end, n.start + model.span / 2000);
768
- while (!free(r, a, b)) r++;
769
- (rows[r] = rows[r] || []).push([a, b]);
770
- out.set(n.id, r);
771
- [...n.children].sort((p, q) => p.start - q.start).forEach((c) => place(c, r));
772
- })(model.root, -1);
773
- return { rowOf: out, count: rows.length };
774
- }
775
- function renderFlame() {
776
- d3.select(el.flame).selectAll("svg").remove();
777
- flameRows = flameRows || packFlame();
778
- flameSvg = d3
779
- .select(el.flame)
780
- .append("svg")
781
- .style("height", `${Math.max(el.flame.clientHeight, 16 + flameRows.count * FLAME_ROW)}px`)
782
- .on("mousemove", (ev) => moveCursor(d3.pointer(ev)[0]))
783
- .on("mouseleave", () => moveCursor(null));
784
- flameSvg.append("g").attr("class", "grid");
785
- flameSvg.append("g").attr("class", "cells");
786
- flameSvg.append("line").attr("class", "pin").attr("stroke", css("--accent")).attr("stroke-width", 1.5).attr("y1", 0).attr("y2", 4000);
787
- flameSvg.append("line").attr("class", "cursor").attr("y1", 0).attr("y2", 4000).attr("visibility", "hidden");
788
- flameSvg.call(flameZoom).call(flameZoom.transform, state.zoom);
789
- redrawX();
790
- }
791
- function drawFlame(x) {
792
- if (!flameSvg) return;
793
- const px = state.pin != null ? x(state.pin) : -10;
794
- queueMicrotask(() => (app.classList.contains("hl-on") || state.errHl) && applyHl());
795
- flameSvg.select("line.pin").attr("x1", px).attr("x2", px);
796
- const H = el.flame.clientHeight;
797
- const ticks = x.ticks(Math.max(2, Math.floor(W / 90)));
798
- flameSvg
799
- .select(".grid")
800
- .selectAll("line")
801
- .data(ticks)
802
- .join("line")
803
- .attr("x1", (d) => x(d))
804
- .attr("x2", (d) => x(d))
805
- .attr("y1", 0)
806
- .attr("y2", H);
807
- const cells = flameSvg
808
- .select(".cells")
809
- .selectAll("g.cell")
810
- .data(model.nodes, (n) => n.id)
811
- .join((enter) => {
812
- const g = enter.append("g").attr("class", "cell");
813
- g.append("rect").attr("class", "flame-rect").attr("rx", 3).attr("height", FLAME_ROW - 2);
814
- g.append("text").attr("class", "flame-text").attr("y", FLAME_ROW / 2 + 3);
815
- return g;
816
- })
817
- .attr("transform", (n) => `translate(0,${8 + flameRows.rowOf.get(n.id) * FLAME_ROW})`);
818
- cells
819
- .select("rect")
820
- .attr("x", (n) => x(n.start))
821
- .attr("width", (n) => Math.max(1, x(n.end) - x(n.start)))
822
- .attr("fill", (n) => colorOf(n))
823
- .attr("fill-opacity", (n) => (n.status === "cancelled" ? 0.55 : 0.92))
824
- .classed("selected", (n) => n.id === state.selected)
825
- .call(bindNode);
826
- cells
827
- .select("text")
828
- .attr("x", (n) => Math.max(x(n.start), 0) + 5)
829
- .text((n) => {
830
- const w = x(n.end) - Math.max(x(n.start), 0);
831
- return w < 34 ? "" : truncate(`${displayName(n)} ${fmtDur(n.duration)}`, (w - 10) / 6.2);
832
- })
833
- .call(bindNode);
834
- }
835
-
836
720
  // ── flow view ──
837
721
  //
838
722
  // Each node's children form one *lane*: a column to its right, in start order, joined by
@@ -842,7 +726,19 @@
842
726
  let flowG = null;
843
727
  let flowPos = new Map();
844
728
  let flowBounds = null;
845
- const FLOW = { CW: 250, CH: 58, GX: 64, GY: 26, LANE_DX: 22, TW: 46, TH: 28 };
729
+ const FLOW = { CW: 250, CH: 58, TAGS_H: 18, GX: 64, GY: 26, LANE_DX: 22, TW: 46, TH: 28 };
730
+ /** A tag chip in SVG, matching the HTML `.chip`; returns its group and width. */
731
+ function svgChip(parent, x, label, color) {
732
+ const g = parent.append("g").attr("class", "tchip").attr("transform", `translate(${x},0)`);
733
+ const rect = g.append("rect").attr("height", 16).attr("rx", 4);
734
+ const text = g.append("text").attr("x", 6).attr("y", 11.5).text(label);
735
+ const w = (text.node().getComputedTextLength() || label.length * 6) + 12;
736
+ rect
737
+ .attr("width", w)
738
+ .attr("fill", color ? alpha(color, 0.14) : "none")
739
+ .attr("stroke", color ? alpha(color, 0.45) : css("--line"));
740
+ return { g, w };
741
+ }
846
742
  function renderFlow() {
847
743
  d3.select(el.flow).selectAll("svg").remove();
848
744
  const svg = d3.select(el.flow).append("svg");
@@ -860,7 +756,9 @@
860
756
  .attr("class", "flow-arrowhead")
861
757
  .attr("d", "M0,1 L10,5 L0,9 z");
862
758
  flowG = svg.append("g");
863
- const { CW, CH, GX, GY, LANE_DX, TW, TH } = FLOW;
759
+ const { CW, GX, GY, LANE_DX, TW, TH } = FLOW;
760
+ // every card gets the tag row when any is tagged, so lanes stay on one rhythm
761
+ const CH = FLOW.CH + (model.nodes.some((n) => n.v.tags && n.v.tags.length) ? FLOW.TAGS_H : 0);
864
762
 
865
763
  const shotsBy = new Map();
866
764
  for (const g of model.gallery) {
@@ -988,7 +886,7 @@
988
886
  .attr("color", (n) => colorOf(n))
989
887
  .html((n) => `<path d="${ICON[iconOf(n)]}" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>`);
990
888
  card.each(function (n) {
991
- const lines = wrapName(displayName(n), 31);
889
+ const lines = wrapName(boxLabel(n), 31);
992
890
  const t = d3.select(this);
993
891
  lines.forEach((line, i) =>
994
892
  t
@@ -1003,6 +901,21 @@
1003
901
  .attr("x", 42)
1004
902
  .attr("y", lines.length > 1 ? 49 : 41)
1005
903
  .text(`${nodeKind(n)} · ${n.status} · ${fmtDur(n.duration)} · +${fmtTick(n.start)}`);
904
+ const tags = n.v.tags || [];
905
+ if (!tags.length) return;
906
+ const row = t.append("g").attr("class", "tchips").attr("transform", `translate(42,${lines.length > 1 ? 56 : 51})`);
907
+ const room = CW - 42 - 10;
908
+ let x = 0;
909
+ for (let i = 0; i < tags.length; i++) {
910
+ const c = svgChip(row, x, truncate(tags[i], 22), tagColor(tags[i]));
911
+ // keep room for a "+N" after it, unless it is the last
912
+ if (i > 0 && x + c.w > room - (i < tags.length - 1 ? 30 : 0)) {
913
+ c.g.remove();
914
+ svgChip(row, x, `+${tags.length - i}`, null).g.classed("more", true);
915
+ break;
916
+ }
917
+ x += c.w + 4;
918
+ }
1006
919
  });
1007
920
  card
1008
921
  .append("circle")
@@ -1011,7 +924,7 @@
1011
924
  .attr("r", 4)
1012
925
  .attr("fill", (n) => css(statusVar(n.status)));
1013
926
  const fcb = card
1014
- .filter((n) => hasCode(n))
927
+ .filter((n) => hasPopup(n))
1015
928
  .append("g")
1016
929
  .attr("class", "code-btn")
1017
930
  .attr("transform", `translate(${CW / 2},0)`)
@@ -1019,27 +932,12 @@
1019
932
  ev.stopPropagation();
1020
933
  toggleCode(n, ev);
1021
934
  })
1022
- .on("mouseenter", (ev) => {
935
+ .on("mouseenter", (ev, n) => {
1023
936
  ev.stopPropagation();
1024
- showTip(ev, "Show source");
937
+ showTip(ev, popupTip(n));
1025
938
  });
1026
939
  fcb.append("circle").attr("r", 9);
1027
940
  fcb.append("text").attr("text-anchor", "middle").attr("y", 3.5).text("<>");
1028
- const frb = card
1029
- .filter((n) => hasValue(n))
1030
- .append("g")
1031
- .attr("class", "code-btn ret")
1032
- .attr("transform", (n) => `translate(${CW / 2 + (hasCode(n) ? 22 : 0)},0)`)
1033
- .on("click", (ev, n) => {
1034
- ev.stopPropagation();
1035
- toggleCode(n, ev, "value", true);
1036
- })
1037
- .on("mouseenter", (ev, n) => {
1038
- ev.stopPropagation();
1039
- showTip(ev, `Returned: <span class="mono">${esc(valuePreview(n, 80))}</span>`);
1040
- });
1041
- frb.append("circle").attr("r", 9);
1042
- frb.append("path").attr("d", ICON.ret).attr("transform", "translate(-6,-6) scale(0.5)");
1043
941
 
1044
942
  // snapshot chips — on lane arrows, and in strips under leaf cards
1045
943
  const chipLayer = flowG.append("g").attr("class", "flow-shots");
@@ -1182,7 +1080,7 @@
1182
1080
  // ── selection + details ──
1183
1081
  function select(id, tab) {
1184
1082
  state.selected = id;
1185
- if (tab) state.detailTab = tab;
1083
+ if (tab) state.detailOpen.add(tab);
1186
1084
  state.details = true;
1187
1085
  el.main.classList.remove("no-details");
1188
1086
  el.tree.querySelectorAll(".row").forEach((r) => r.classList.toggle("selected", r.dataset.id === id));
@@ -1190,7 +1088,6 @@
1190
1088
  chartSvg.selectAll("rect.bar").classed("selected", (n) => n.id === id);
1191
1089
  chartSvg.selectAll("g.r").classed("sel", (n) => n.id === id);
1192
1090
  }
1193
- if (flameSvg) flameSvg.selectAll("rect.flame-rect").classed("selected", (n) => n.id === id);
1194
1091
  d3.select(el.flow).selectAll("g.flow-card").classed("selected", (n) => n.id === id);
1195
1092
  renderDetails();
1196
1093
  }
@@ -1240,10 +1137,9 @@
1240
1137
  flash(matchMark);
1241
1138
  }
1242
1139
  function flash(matchMark) {
1243
- const svgs = [chartSvg, flameSvg].filter(Boolean);
1244
- for (const s of svgs) {
1245
- s.selectAll("line.pin").classed("flash", false).node()?.getBoundingClientRect();
1246
- s.selectAll("line.pin").classed("flash", true);
1140
+ if (chartSvg) {
1141
+ chartSvg.selectAll("line.pin").classed("flash", false).node()?.getBoundingClientRect();
1142
+ chartSvg.selectAll("line.pin").classed("flash", true);
1247
1143
  }
1248
1144
  if (chartSvg) {
1249
1145
  const hit = chartSvg
@@ -1266,15 +1162,7 @@
1266
1162
  function renderDetails() {
1267
1163
  const n = model.byId.get(state.selected) || model.root;
1268
1164
  const color = colorOf(n);
1269
- const tabs = [
1270
- ["overview", "Overview"],
1271
- ["args", "Args"],
1272
- ...(hasValue(n) ? [["value", "Return"]] : []),
1273
- ...(model.errorsBy.get(n) ? [["errors", `Errors (${model.errorsBy.get(n)})`]] : []),
1274
- ["events", `Events`],
1275
- ["logs", "Logs"],
1276
- ["snapshots", "Snapshots"],
1277
- ];
1165
+ const call = callOf(n);
1278
1166
  el.details.innerHTML = `
1279
1167
  <div class="dh">Action details <button class="x" title="Close">${svgIcon("close", ' width="16" height="16"')}</button></div>
1280
1168
  <div class="who">
@@ -1283,25 +1171,30 @@
1283
1171
  <span class="pill"><span class="dot ${n.status}"></span>${esc(n.status)}</span>
1284
1172
  </div>
1285
1173
  <div class="scroll">
1174
+ ${call ? `<div class="tcall who-call">${callHtml(n, call)}</div>` : ""}
1286
1175
  <div class="kv">
1287
1176
  <div class="k">Duration</div><div class="v">${fmtDur(n.duration)}</div>
1288
- <div class="k">Start</div><div class="v">${fmtRel(n.start)} (${fmtClock(n.startAbs)})</div>
1289
- <div class="k">End</div><div class="v">${fmtRel(n.end)} (${fmtClock(n.endAbs)})</div>
1290
1177
  ${n.v.description ? `<div class="k">Summary</div><div class="v">${esc(n.v.description.summary)}</div>` : ""}
1178
+ ${n.v.tags.length ? `<div class="k">Tags</div><div class="v">${chipsHtml(n.v.tags, Infinity, 40)}</div>` : ""}
1291
1179
  ${n.v.mcp ? `<div class="k">MCP</div><div class="v"><span class="badge-inline">${esc(n.v.mcp.mode)}</span></div>` : ""}
1292
- <div class="k">Path</div><div class="v">${esc(n.v.path)}</div>
1180
+ <div class="k">Id</div><div class="v mono-sm">${esc(n.id)}</div>
1293
1181
  ${userSite(n) ? `<div class="k">${siteLabel(n)}</div><div class="v defined">
1294
1182
  <button class="icon-btn" data-copy-source aria-label="Copy relative path">${svgIcon("copy")}</button>
1295
- ${hasCode(n) ? `<button class="icon-btn" data-show-code aria-label="Show source">${svgIcon("code")}</button>` : ""}
1296
- ${hasValue(n) ? `<button class="icon-btn" data-show-value aria-label="Show returned value">${svgIcon("ret")}</button>` : ""}
1183
+ ${hasPopup(n) ? `<button class="icon-btn" data-show-code aria-label="Show source">${svgIcon("code")}</button>` : ""}
1297
1184
  <a class="icon-btn" href="${esc(editorUrl(userSite(n)))}" aria-label="Open in VS Code" data-tip-text="Open in VS Code">${svgIcon("external")}</a>
1298
1185
  <span class="defined-name" >${esc(basename(sourceOf(n)))}</span>
1299
1186
  </div>` : ""}
1300
1187
  </div>
1301
- <div class="tabs">${tabs
1302
- .map(([k, l]) => `<button class="tab ${state.detailTab === k ? "active" : ""}" data-tab="${k}">${l}</button>`)
1188
+ <div class="acc">${detailSections(n)
1189
+ .map((sec) =>
1190
+ sec.empty
1191
+ ? `<div class="acc-sec empty"><div class="acc-h">${svgIcon("chevron")}<span>${esc(sec.title)}</span><span class="acc-n">0</span></div></div>`
1192
+ : `<details class="acc-sec ${sec.key}" data-sec="${sec.key}" ${state.detailOpen.has(sec.key) ? "open" : ""}>
1193
+ <summary class="acc-h">${svgIcon("chevron")}<span>${esc(sec.title)}</span>${sec.count != null ? `<span class="acc-n">${sec.count}</span>` : ""}</summary>
1194
+ <div class="acc-b">${sec.body()}</div>
1195
+ </details>`
1196
+ )
1303
1197
  .join("")}</div>
1304
- <div class="tabbody">${detailTabHtml(n)}</div>
1305
1198
  </div>`;
1306
1199
  const copySrc = el.details.querySelector("[data-copy-source]");
1307
1200
  if (copySrc) {
@@ -1312,17 +1205,11 @@
1312
1205
  copySrc.addEventListener("mousemove", (ev) => moveTip(ev));
1313
1206
  copySrc.addEventListener("mouseleave", hideTip);
1314
1207
  }
1315
- const showValue = el.details.querySelector("[data-show-value]");
1316
- if (showValue) {
1317
- showValue.addEventListener("click", (ev) => toggleCode(n, ev, "value", true));
1318
- showValue.addEventListener("mouseenter", (ev) => showTip(ev, `Returned: <span class="mono">${esc(valuePreview(n, 80))}</span>`));
1319
- showValue.addEventListener("mouseleave", hideTip);
1320
- }
1321
1208
  wireValue(el.details, n);
1322
1209
  const showCode = el.details.querySelector("[data-show-code]");
1323
1210
  if (showCode) {
1324
1211
  showCode.addEventListener("click", (ev) => toggleCode(n, ev));
1325
- showCode.addEventListener("mouseenter", (ev) => showTip(ev, "Show source"));
1212
+ showCode.addEventListener("mouseenter", (ev) => showTip(ev, popupTip(n)));
1326
1213
  showCode.addEventListener("mouseleave", hideTip);
1327
1214
  }
1328
1215
  el.details.querySelectorAll("[data-tip-text]").forEach((a) => {
@@ -1334,67 +1221,88 @@
1334
1221
  el.main.classList.add("no-details");
1335
1222
  relayout();
1336
1223
  });
1337
- el.details.querySelectorAll(".tab").forEach((b) =>
1338
- b.addEventListener("click", () => {
1339
- state.detailTab = b.dataset.tab;
1340
- renderDetails();
1224
+ // which sections are open is a preference: it carries over from action to action
1225
+ el.details.querySelectorAll("details[data-sec]").forEach((d) =>
1226
+ d.addEventListener("toggle", () => {
1227
+ if (d.open) state.detailOpen.add(d.dataset.sec);
1228
+ else state.detailOpen.delete(d.dataset.sec);
1341
1229
  })
1342
1230
  );
1343
1231
  wireItems(el.details);
1344
1232
  }
1345
1233
 
1346
- function detailTabHtml(n) {
1347
- const tab = state.detailTab;
1348
- if (tab === "errors") {
1349
- const list = model.errors.filter((e) => e.node === n);
1350
- if (!list.length) return '<div class="muted">No errors.</div>';
1351
- return list
1352
- .map((e) => {
1353
- const kind = ERR_TYPES.find(([k]) => k === e.type)[1];
1354
- return `<div class="section-title err-kind ${e.type}">${esc(kind)} <span class="muted">${fmtRel(e.t - n.start)}</span></div><pre class="code err-text">${esc(e.text)}</pre>`;
1355
- })
1356
- .join("");
1357
- }
1358
- if (tab === "value") {
1359
- return hasValue(n) ? `<div class="code-box">${valueHtml(n)}</div>` : '<div class="muted">Returned nothing.</div>';
1360
- }
1361
- if (tab === "args") {
1362
- const params = schemaFields(n.v.inputSchema);
1363
- const schema = params.length ? `<div class="section-title">Schema</div><div class="params">${params.map(paramHtml).join("")}</div><div class="section-title">Values</div>` : "";
1364
- return schema + (n.v.args && n.v.args.length ? `<pre class="code">${esc(json(n.v.args.length === 1 ? n.v.args[0] : n.v.args))}</pre>` : '<div class="muted">No arguments.</div>');
1365
- }
1366
- if (tab === "events") {
1367
- const evs = n.entries.filter((i) => i.kind !== "info");
1368
- return evs.length ? `<ul class="ev-list">${evs.map((i) => evLi(i, n)).join("")}</ul>` : '<div class="muted">No events.</div>';
1369
- }
1370
- if (tab === "logs") {
1371
- const logs = n.entries.filter((i) => i.kind === "info" || i.kind === "error");
1372
- return logs.length
1373
- ? `<pre class="code">${logs.map((i) => `<span class="muted">${esc(fmtRel(i.t - n.start).padEnd(8))}</span>${esc(i.e.message)}`).join("\n")}</pre>`
1374
- : '<div class="muted">No log output.</div>';
1375
- }
1376
- if (tab === "snapshots") {
1377
- const g = model.gallery.filter((x) => x.node === n);
1378
- return g.length ? `<div class="thumbs">${g.map(thumbHtml).join("")}</div>` : '<div class="muted">No snapshots or files.</div>';
1379
- }
1380
- // overview
1381
- const fails = model.run.failures.filter((f) => f.action === n.id || f.action === n.name || f.action === n.v.path);
1234
+ /** The details panel's accordion: one section per facet of `n`, empty ones shown but shut. */
1235
+ function detailSections(n) {
1236
+ const errs = model.errors.filter((e) => e.node === n);
1237
+ const params = schemaFields(n.v.inputSchema);
1238
+ const evs = n.entries.filter((i) => i.kind !== "info");
1239
+ const logs = n.entries.filter((i) => i.kind === "info" || i.kind === "error");
1240
+ const shots = model.gallery.filter((x) => x.node === n);
1382
1241
  const asserts = n.entries.filter((i) => i.kind === "pass" || i.kind === "fail");
1383
- const g = model.gallery.filter((x) => x.node === n);
1384
- return `
1385
- <div class="kv">
1386
- <div class="k">Id</div><div class="v">${esc(n.id)}</div>
1387
- ${n.name !== displayName(n) ? `<div class="k">Name</div><div class="v">${esc(n.name)}</div>` : ""}
1388
- <div class="k">Tags</div><div class="v">${n.v.tags.length ? esc(n.v.tags.join(", ")) : '<span class="muted">none</span>'}</div>
1389
- <div class="k">Children</div><div class="v">${n.children.length}</div>
1390
- <div class="k">Events</div><div class="v">${n.marks.length}</div>
1391
- </div>
1392
- ${fails.length ? `<div class="section-title" style="color:var(--fail)">Failures (${fails.length})</div>${fails.map((f) => `<pre class="code">${esc(f.error)}</pre>`).join("")}` : ""}
1393
- ${g.length ? `<div class="section-title">Snapshots (${g.length})</div><div class="thumbs">${g.slice(0, 6).map(thumbHtml).join("")}</div>` : ""}
1394
- <div class="section-title">Related logs</div>
1395
- <div class="kv"><div class="k">${svgIcon("file", ' width="14" height="14"')}</div><div class="v"><a href="${esc(fileUrl(n.v.logFile))}" title="${esc(n.v.logFile)}">${esc(basename(n.v.logFile))}</a></div></div>
1396
- <div class="section-title">Assertions (${asserts.length})</div>
1397
- ${asserts.length ? `<ul class="ev-list">${asserts.map((i) => evLi(i, n)).join("")}</ul>` : '<div class="muted">No assertions in this action.</div>'}`;
1242
+ const logLink = `<div class="acc-file">${svgIcon("file", ' width="14" height="14"')}<a href="${esc(fileUrl(n.v.logFile))}" title="${esc(n.v.logFile)}">${esc(basename(n.v.logFile))}</a></div>`;
1243
+ return [
1244
+ {
1245
+ key: "errors",
1246
+ title: "Errors",
1247
+ count: errs.length,
1248
+ empty: !errs.length,
1249
+ body: () =>
1250
+ errs
1251
+ .map((e) => {
1252
+ const kind = ERR_TYPES.find(([k]) => k === e.type)[1];
1253
+ return `<div class="section-title err-kind ${e.type}">${esc(kind)} <span class="muted">${fmtRel(e.t - n.start)}</span></div><pre class="code err-text">${esc(e.text)}</pre>`;
1254
+ })
1255
+ .join(""),
1256
+ },
1257
+ {
1258
+ key: "args",
1259
+ title: "Args",
1260
+ empty: !hasArgs(n) && !params.length,
1261
+ body: () =>
1262
+ (params.length ? `<div class="params">${params.map(paramHtml).join("")}</div>` : "") +
1263
+ (hasArgs(n) ? `<div class="code-box">${argsHtml(n)}</div>` : '<div class="muted">No arguments.</div>'),
1264
+ },
1265
+ { key: "value", title: "Returned", empty: !hasValue(n), body: () => `<div class="code-box">${valueHtml(n)}</div>` },
1266
+ {
1267
+ key: "snapshots",
1268
+ title: "Snapshots & files",
1269
+ count: shots.length,
1270
+ empty: !shots.length,
1271
+ body: () => `<div class="shots">${shots.map((g) => shotHtml(g, n)).join("")}</div>`,
1272
+ },
1273
+ {
1274
+ key: "events",
1275
+ title: "Events",
1276
+ count: evs.length,
1277
+ empty: !evs.length,
1278
+ body: () => `<ul class="ev-list">${evs.map((i) => evLi(i, n)).join("")}</ul>`,
1279
+ },
1280
+ {
1281
+ key: "logs",
1282
+ title: "Logs",
1283
+ count: logs.length,
1284
+ empty: !logs.length,
1285
+ body: () =>
1286
+ `${logLink}<pre class="code">${logs.map((i) => `<span class="muted">${esc(fmtRel(i.t - n.start).padEnd(8))}</span>${esc(i.e.message)}`).join("\n")}</pre>`,
1287
+ },
1288
+ {
1289
+ key: "assertions",
1290
+ title: "Assertions",
1291
+ count: asserts.length,
1292
+ empty: !asserts.length,
1293
+ body: () => `<ul class="ev-list">${asserts.map((i) => evLi(i, n)).join("")}</ul>`,
1294
+ },
1295
+ ];
1296
+ }
1297
+
1298
+ /** A snapshot or file in the details panel: a small thumb with its name and when it landed. */
1299
+ function shotHtml(g, n) {
1300
+ const i = model.gallery.indexOf(g);
1301
+ return `<div class="shot" data-gallery="${i}" title="${esc(g.name)}">
1302
+ ${thumbHtml(g)}
1303
+ <span class="nm">${esc(g.name)}</span>
1304
+ ${g.t != null ? `<span class="t">${fmtRel(g.t - n.start)}</span>` : ""}
1305
+ </div>`;
1398
1306
  }
1399
1307
 
1400
1308
  function evLi(i, n) {
@@ -1561,7 +1469,6 @@
1561
1469
  const mark = (sel, idOf) =>
1562
1470
  sel.classed("err", (d) => !!set && set.has(idOf(d))).classed("err-path", (d) => !!set && path.has(idOf(d)));
1563
1471
  if (chartSvg) mark(chartSvg.selectAll("g.r"), (n) => n.id);
1564
- if (flameSvg) mark(flameSvg.selectAll("g.cell"), (n) => n.id);
1565
1472
  mark(d3.select(el.flow).selectAll("g.flow-card"), (n) => n.id);
1566
1473
  el.tree.querySelectorAll(".row").forEach((r) => {
1567
1474
  r.classList.toggle("err", !!set && set.has(r.dataset.id));
@@ -1762,40 +1669,20 @@
1762
1669
  hovered = n; // the node `C` copies while its tooltip is up
1763
1670
  const v = n.v;
1764
1671
  const shown = n.depth === 0 ? model.data.name : displayName(n);
1765
- const badges = [
1766
- `<span class="badge"><span class="dot ${n.status}"></span>${esc(n.status)}</span>`,
1767
- v.mcp ? `<span class="badge mcp" title="Exposed over MCP">MCP · ${esc(v.mcp.mode)}</span>` : "",
1768
- n.depth === 0 ? '<span class="badge">om</span>' : "",
1769
- n.v.kind === "step" ? '<span class="badge" title="An inline step(…), not a reusable action">step</span>' : "",
1770
- ].join("");
1771
- const rows = [
1772
- ["Duration", fmtDur(n.duration)],
1773
- ["When", `${fmtRel(n.start)} → ${fmtRel(n.end)}`],
1774
- v.tags.length ? ["Tags", esc(v.tags.join(" · "))] : null,
1775
- userSite(n) ? [siteLabel(n) === "Called at" ? "Called" : "Defined", `<span class="mono">${esc(sourceOf(n))}</span>`] : null,
1776
- hasValue(n) ? ["Returns", `<span class="mono">${esc(valuePreview(n, 90))}</span>`] : null,
1777
- n.children.length ? ["Children", String(n.children.length)] : null,
1778
- n.marks.length ? ["Events", String(n.marks.length)] : null,
1672
+ const call = callOf(n);
1673
+ const meta = [
1674
+ `<span class="dot ${n.status}"></span>${esc(n.status)}`,
1675
+ esc(fmtDur(n.duration)),
1676
+ n.depth === 0 ? "om" : v.kind === "step" ? "step" : "",
1677
+ v.mcp ? `<span class="mcp">MCP · ${esc(v.mcp.mode)}</span>` : "",
1779
1678
  ].filter(Boolean);
1780
- const args = !promptOf(n) && v.args && v.args.length ? truncate(oneLine(v.args.length === 1 ? v.args[0] : v.args), 220) : "";
1781
- const params = schemaFields(v.inputSchema);
1782
- const pr = promptOf(n);
1783
- const promptRows = pr
1784
- ? `<div class="sec">Prompt</div><div class="tprompt">${esc(truncate(pr.message || "", 200))}</div>${
1785
- pr.answer ? `<div class="tkv"><span class="k">Answer</span><span class="mono">${esc(truncate(typeof pr.answer.value === "string" ? pr.answer.value : oneLine(pr.answer.value), 120))}</span>${pr.answer.via ? `<span class="k">Via</span><span>${esc(pr.answer.via)}</span>` : ""}</div>` : ""
1786
- }`
1787
- : "";
1788
1679
  const fails = model.run.failures.filter((f) => f.action === n.id || f.action === n.name || f.action === v.path);
1789
1680
  return `<div class="tt">${esc(shown)}</div>
1790
- ${shown !== n.name ? `<div class="ts mono">${esc(truncate(n.name, 140))}</div>` : ""}
1791
- <div class="badges">${badges}</div>
1792
- ${v.description ? `<div class="summary">${esc(v.description.summary)}</div>` : '<div class="summary muted">No description.</div>'}
1793
- <div class="tkv">${rows.map(([k, val]) => `<span class="k">${k}</span><span>${val}</span>`).join("")}</div>
1794
- ${promptRows}
1795
- ${params.length ? `<div class="sec">Args schema</div><div class="params">${params.map(paramHtml).join("")}</div>` : ""}
1796
- ${args ? `<div class="sec">Args</div><div class="mono small">${esc(args)}</div>` : ""}
1797
- ${v.definedAt ? '<div class="hint">Press <kbd>C</kbd> to copy the path</div>' : ""}
1798
- ${fails.length ? `<div class="sec fail">Failed</div><div class="mono small">${esc(truncate(String(fails[0].error).split(String.fromCharCode(10))[0], 200))}</div>` : ""}`;
1681
+ ${call ? `<div class="tcall">${callHtml(n, call)}</div>` : ""}
1682
+ <div class="tmeta">${meta.join('<span class="sep">·</span>')}</div>
1683
+ ${v.description ? `<div class="summary">${esc(truncate(v.description.summary, 240))}</div>` : ""}
1684
+ ${chipsHtml(v.tags, TIP_TAGS, 28)}
1685
+ ${fails.length ? `<div class="tfail">${esc(truncate(String(fails[0].error).split(String.fromCharCode(10))[0], 160))}</div>` : ""}`;
1799
1686
  }
1800
1687
  function galleryTip(x) {
1801
1688
  const img = x.image ? `<img src="${model.asset(x.f.asset)}" alt="">` : "";
@@ -1844,19 +1731,89 @@
1844
1731
  function codeOf(site) {
1845
1732
  return site && model.data.code ? model.data.code[site] : undefined;
1846
1733
  }
1847
- /** The popover's tabs for `n`, most useful first. */
1734
+ /**
1735
+ * How the user's code launched `n`, read off the source at its call site: the variable it
1736
+ * was bound to and the callee — `page = chromePage(…)`. Sites carry a line but no column,
1737
+ * so this takes the first call on that line; null when the site is library source.
1738
+ */
1739
+ function callExpr(n) {
1740
+ const site = n.v.calledAt;
1741
+ const c = site && !isLib(site) && codeOf(site);
1742
+ if (!c) return null;
1743
+ const at = Number((/:(\d+)$/.exec(site) || [])[1]);
1744
+ const lines = c.text.split("\n");
1745
+ const line = lines[at - c.start];
1746
+ if (line == null) return null;
1747
+ let call = null;
1748
+ for (const m of line.matchAll(/(?:^|[^\w$.])([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\(/g)) {
1749
+ if (!/^(if|for|while|switch|return|catch|function|await|typeof|new|async)$/.test(m[1])) {
1750
+ call = { callee: m[1], index: m.index };
1751
+ break;
1752
+ }
1753
+ }
1754
+ if (!call) return null;
1755
+ const DECL = /\b(?:const|let|var)\s+([\w$]+|\{[^}]*\}|\[[^\]]*\])/;
1756
+ const prefix = line.slice(0, call.index);
1757
+ const prev = lines[at - c.start - 1] || "";
1758
+ const m =
1759
+ DECL.exec(prefix) ||
1760
+ /^\s*([\w$.]+)\s*=(?!=)/.exec(prefix) ||
1761
+ (/^\s*(?:await\s+)?$/.test(prefix) && /=\s*(?:await\s*)?$/.test(prev) ? DECL.exec(prev) : null);
1762
+ return { callee: call.callee, bind: m ? m[1].replace(/\s+/g, " ") : "" };
1763
+ }
1764
+ /** The popover's tabs for `n`: what it is (prompt, source), then what went in and came out. */
1848
1765
  function codeTabs(n) {
1849
1766
  const tabs = [];
1850
1767
  if (promptOf(n)) tabs.push({ key: "prompt", label: "Prompt" });
1851
- if (hasValue(n)) tabs.push({ key: "value", label: "Returned" });
1852
1768
  const mine = userSite(n);
1853
1769
  if (codeOf(mine)) tabs.push({ key: "user", label: siteLabel(n) === "Called at" ? "Call site" : "Source", site: mine });
1854
1770
  const def = n.v.definedAt;
1855
1771
  if (def && def !== mine && codeOf(def)) tabs.push({ key: "def", label: isLib(def) ? "Definition (omkit)" : "Definition", site: def });
1772
+ if (hasArgs(n)) tabs.push({ key: "args", label: "Args" });
1773
+ if (hasValue(n)) tabs.push({ key: "value", label: "Returned" });
1856
1774
  return tabs;
1857
1775
  }
1858
1776
  function hasCode(n) {
1859
- return codeTabs(n).some((t) => t.key !== "value");
1777
+ return codeTabs(n).some((t) => t.key !== "value" && t.key !== "args");
1778
+ }
1779
+ const callMemo = new Map();
1780
+ /** {@link callExpr}, once per node — bar labels ask on every redraw. */
1781
+ function callOf(n) {
1782
+ if (!callMemo.has(n.id)) callMemo.set(n.id, n.depth === 0 ? null : callExpr(n));
1783
+ return callMemo.get(n.id);
1784
+ }
1785
+ /**
1786
+ * An action box's label: the call that launched it, as the tooltip shows it —
1787
+ * `watchBuildDirectory = watchDir(…)` — then omkit's name when the callee does not already
1788
+ * say it (`command(…) · npm run dev`). Just the name when the call is unknown.
1789
+ */
1790
+ function boxLabel(n) {
1791
+ const c = callOf(n);
1792
+ if (!c) return displayName(n);
1793
+ const call = `${c.bind ? `${c.bind} = ` : ""}${c.callee}(${hasArgs(n) ? "…" : ""})`;
1794
+ return c.callee === displayName(n) ? call : `${call} · ${displayName(n)}`;
1795
+ }
1796
+ function labelHtml(n) {
1797
+ const c = callOf(n);
1798
+ if (!c) return esc(displayName(n));
1799
+ const name = c.callee === displayName(n) ? "" : `<span class="sep"> · </span>${esc(displayName(n))}`;
1800
+ return `<span class="tcall">${callHtml(n, c)}</span>${name}`;
1801
+ }
1802
+ /** `page = chromePage(…)`, from {@link callExpr}. */
1803
+ function callHtml(n, call) {
1804
+ return `${call.bind ? `<span class="b">${esc(call.bind)}</span> = ` : ""}<span class="f">${esc(call.callee)}</span>(${hasArgs(n) ? "…" : ""})`;
1805
+ }
1806
+ function hasPopup(n) {
1807
+ return codeTabs(n).length > 0;
1808
+ }
1809
+ function popupTip(n) {
1810
+ return codeTabs(n).map((t) => esc(t.label)).join(" · ");
1811
+ }
1812
+ function hasArgs(n) {
1813
+ return !!(n.v.args && n.v.args.length);
1814
+ }
1815
+ function argsOf(n) {
1816
+ return n.v.args.length === 1 ? n.v.args[0] : n.v.args;
1860
1817
  }
1861
1818
  function relOf(site) {
1862
1819
  return (model.data.sources && model.data.sources[site]) || String(site).replace(/:\d+(:\d+)?$/, "");
@@ -1864,27 +1821,32 @@
1864
1821
  function hasValue(n) {
1865
1822
  return n.v.value !== undefined || !!n.v.valueNote;
1866
1823
  }
1867
- function valuePreview(n, max) {
1868
- if (n.v.value === undefined) return n.v.valueNote || "";
1869
- return truncate(oneLine(n.v.value), max);
1870
- }
1871
- /** The returned value in the code view: pretty JSON, highlighted, numbered, copyable. */
1872
- function valueHtml(n) {
1873
- if (n.v.value === undefined) {
1874
- return `<div class="pp"><div class="muted">${esc(n.v.valueNote || "Returned nothing.")}</div></div>`;
1875
- }
1876
- const text = json(n.v.value);
1824
+ /** A value in the code view: pretty JSON, highlighted, numbered, copyable. */
1825
+ function jsonHtml(value, what) {
1826
+ const text = json(value);
1877
1827
  const lines = highlight(text);
1878
- const kind = Array.isArray(n.v.value) ? `array · ${n.v.value.length} items` : n.v.value === null ? "null" : typeof n.v.value === "object" ? `object · ${Object.keys(n.v.value).length} keys` : typeof n.v.value;
1828
+ const kind = Array.isArray(value) ? `array · ${value.length} items` : value === null ? "null" : typeof value === "object" ? `object · ${Object.keys(value).length} keys` : typeof value;
1879
1829
  return `<div class="cp-tools"><span class="muted">${esc(kind)} · ${lines.length} line${lines.length > 1 ? "s" : ""} · ${fmtSize(text.length)}</span>
1880
- <button class="btn solo small" data-a="copy-json">${svgIcon("copy")}Copy JSON</button></div>
1830
+ <button class="btn solo small" data-a="copy-json" data-what="${what}">${svgIcon("copy")}Copy JSON</button></div>
1881
1831
  <div class="cp-code">${lines
1882
1832
  .map((html, i) => `<div class="cl"><span class="ln">${i + 1}</span><span class="lc">${html || " "}</span></div>`)
1883
1833
  .join("")}</div>`;
1884
1834
  }
1835
+ function valueHtml(n) {
1836
+ if (n.v.value === undefined) {
1837
+ return `<div class="pp"><div class="muted">${esc(n.v.valueNote || "Returned nothing.")}</div></div>`;
1838
+ }
1839
+ return jsonHtml(n.v.value, "value");
1840
+ }
1841
+ function argsHtml(n) {
1842
+ return hasArgs(n) ? jsonHtml(argsOf(n), "args") : '<div class="pp"><div class="muted">No arguments.</div></div>';
1843
+ }
1885
1844
  function wireValue(root, n) {
1886
- const b = root.querySelector('[data-a="copy-json"]');
1887
- if (b) b.addEventListener("click", () => copy(json(n.v.value), "Copied the returned value"));
1845
+ root.querySelectorAll('[data-a="copy-json"]').forEach((b) =>
1846
+ b.addEventListener("click", () =>
1847
+ b.dataset.what === "args" ? copy(json(argsOf(n)), "Copied the args") : copy(json(n.v.value), "Copied the returned value")
1848
+ )
1849
+ );
1888
1850
  }
1889
1851
  function codeHtml(site) {
1890
1852
  const c = codeOf(site);
@@ -1927,7 +1889,7 @@
1927
1889
  if (codeFor === n.id && (!want || (toggle && codeTab === want))) return closeCode();
1928
1890
  const tabs = codeTabs(n);
1929
1891
  if (!tabs.length) return;
1930
- const tab = tabs.find((t) => t.key === want) || tabs.find((t) => t.key !== "value") || tabs[0];
1892
+ const tab = tabs.find((t) => t.key === want) || tabs.find((t) => t.key !== "value" && t.key !== "args") || tabs[0];
1931
1893
  codeTab = tab.key;
1932
1894
  hideTip();
1933
1895
  const reopen = codeFor !== n.id;
@@ -1943,7 +1905,9 @@
1943
1905
  <button class="icon-btn" data-a="close" aria-label="Close">${svgIcon("close")}</button>
1944
1906
  </div>
1945
1907
  ${tabs.length > 1 ? `<div class="cp-tabs">${tabs.map((t) => `<button class="tab ${t === tab ? "active" : ""}" data-tab="${t.key}">${esc(t.label)}</button>`).join("")}</div>` : ""}
1946
- <div class="cp-body">${tab.key === "prompt" ? promptHtml(promptOf(n)) : tab.key === "value" ? valueHtml(n) : codeHtml(site)}</div>`;
1908
+ <div class="cp-body">${
1909
+ tab.key === "prompt" ? promptHtml(promptOf(n)) : tab.key === "value" ? valueHtml(n) : tab.key === "args" ? argsHtml(n) : codeHtml(site)
1910
+ }</div>`;
1947
1911
  wireValue(codeEl, n);
1948
1912
  const cp = codeEl.querySelector('[data-a="copy"]');
1949
1913
  if (cp) cp.addEventListener("click", () => copy(rel, `Copied ${rel}`));
@@ -1964,7 +1928,7 @@
1964
1928
  if (at) at.scrollIntoView({ block: "center" });
1965
1929
  if (codeAway) document.removeEventListener("mousedown", codeAway, true);
1966
1930
  codeAway = (e) => {
1967
- if (!codeEl.contains(e.target) && !e.target.closest(".code-btn, [data-show-code], [data-show-value]")) closeCode();
1931
+ if (!codeEl.contains(e.target) && !e.target.closest(".code-btn, [data-show-code]")) closeCode();
1968
1932
  };
1969
1933
  setTimeout(() => codeAway && document.addEventListener("mousedown", codeAway, true), 0);
1970
1934
  }
@@ -2020,7 +1984,6 @@
2020
1984
  function renderAll() {
2021
1985
  measure();
2022
1986
  if (state.view === "timeline") renderTimeline();
2023
- if (state.view === "flame") renderFlame();
2024
1987
  if (state.view === "flow") renderFlow();
2025
1988
  renderOverview();
2026
1989
  }
@@ -2094,7 +2057,6 @@
2094
2057
  </div>
2095
2058
  <div class="btn-group">
2096
2059
  <button class="btn active" data-view="timeline">${svgIcon("timeline")}Timeline + Graph</button>
2097
- <button class="btn" data-view="flame">${svgIcon("flame")}Flame Graph</button>
2098
2060
  <button class="btn" data-view="flow">${svgIcon("flow")}Flow</button>
2099
2061
  </div>
2100
2062
  <div class="btn-group">
@@ -2108,20 +2070,15 @@
2108
2070
  <button class="btn solo" id="more" title="More">${svgIcon("more")}</button>
2109
2071
  </header>
2110
2072
  <div class="main">
2111
- <section class="card timeline" style="--tree-w:420px;--row-h:${ROW_H}px;position:relative">
2073
+ <section class="card timeline" style="--tree-w:360px;--row-h:${ROW_H}px;position:relative">
2112
2074
  <div class="tl-head">
2113
- <div class="cols"><div>Action / Event</div><div>Status</div><div>Duration</div></div>
2075
+ <div class="cols"><div>Action / Event</div></div>
2114
2076
  <svg></svg>
2115
2077
  </div>
2116
2078
  <div class="tl-body"><div class="tree"></div><div class="chart"></div></div>
2117
- <div class="flame-view" hidden style="top:34px"></div>
2118
2079
  <div class="flow-view" hidden></div>
2119
- </section>
2120
- <aside class="card details"></aside>
2121
- </div>
2122
- <div class="bottom">
2123
- <section class="card lower"></section>
2124
- <section class="card legend">
2080
+ <button class="info-btn legend-btn" id="legend-btn" aria-label="Legend" aria-expanded="false" title="Legend">${svgIcon("info")}</button>
2081
+ <div class="legend legend-pop" id="legend" hidden>
2125
2082
  <h3>Legend</h3>
2126
2083
  <div class="grid2">
2127
2084
  ${legendItem("--c1", "Action run", "bar")}
@@ -2134,7 +2091,12 @@
2134
2091
  ${legendItem("--accent", "Pinned time", "line")}
2135
2092
  </div>
2136
2093
  <div class="muted" style="margin-top:12px;font-size:11px">Ctrl + wheel to zoom · drag to pan · double-click a bar to focus · ↑/↓ to step</div>
2094
+ </div>
2137
2095
  </section>
2096
+ <aside class="card details"></aside>
2097
+ </div>
2098
+ <div class="bottom">
2099
+ <section class="card lower"></section>
2138
2100
  <section class="card overview"><h3>Overview</h3><div class="range"></div><svg></svg></section>
2139
2101
  </div>`;
2140
2102
  }
@@ -2285,6 +2247,23 @@
2285
2247
  n = Math.max(1, Math.floor(n));
2286
2248
  return s.length > n ? `${s.slice(0, Math.max(1, n - 1))}…` : s;
2287
2249
  }
2250
+ /** A tag's colour: hashed from its text, so a tag reads the same in every view. */
2251
+ function tagColor(t) {
2252
+ let h = 0;
2253
+ for (let i = 0; i < t.length; i++) h = (h * 31 + t.charCodeAt(i)) | 0;
2254
+ return css(PALETTE[Math.abs(h) % PALETTE.length]);
2255
+ }
2256
+ /** One tag as a chip — the tree, tooltips and details share it; the flow draws the same in SVG. */
2257
+ function chipHtml(t, max) {
2258
+ const c = tagColor(t);
2259
+ return `<span class="chip" style="background:${alpha(c, 0.14)};border-color:${alpha(c, 0.45)}" title="${esc(t)}">${esc(truncate(t, max))}</span>`;
2260
+ }
2261
+ /** Up to `limit` tag chips, then a "+N" chip holding the rest. */
2262
+ function chipsHtml(tags, limit, max) {
2263
+ if (!tags || !tags.length) return "";
2264
+ const more = tags.length > limit ? `<span class="chip more" title="${esc(tags.slice(limit).join(", "))}">+${tags.length - limit}</span>` : "";
2265
+ return `<span class="chips">${tags.slice(0, limit).map((t) => chipHtml(t, max)).join("")}${more}</span>`;
2266
+ }
2288
2267
  function displayName(n) {
2289
2268
  if (n.depth === 0) return "main";
2290
2269
  const p = promptOf(n);