jev-devtools 0.1.2 → 0.2.0

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.
@@ -2,6 +2,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import { useEffect, useId, useRef, useState } from "react";
3
3
  import { TypeSafeMark } from "./TypeSafeMark.js";
4
4
  import { createPortal } from "react-dom";
5
+ import { inspectorStyles } from "./inspector-styles.js";
5
6
  const heightKey = "jev-inspector-drawer-height";
6
7
  const viewportHeight = () => window.visualViewport?.height ?? window.innerHeight;
7
8
  const limits = (viewport) => ({
@@ -24,9 +25,9 @@ function initialHeight() {
24
25
  return clamp(Math.min(420, viewportHeight() * 0.56), viewportHeight());
25
26
  }
26
27
  /** Non-modal drawer: the app remains usable and children stay mounted when closed. */
27
- export function JevDrawer({ children, defaultOpen = true, }) {
28
+ export function JevDrawer({ children, open, onOpenChange, }) {
28
29
  const id = useId();
29
- const [open, setOpen] = useState(defaultOpen);
30
+ const [root, setRoot] = useState(null);
30
31
  const [expanded, setExpanded] = useState(false);
31
32
  const [viewport, setViewport] = useState(viewportHeight);
32
33
  const [height, setHeight] = useState(initialHeight);
@@ -37,6 +38,30 @@ export function JevDrawer({ children, defaultOpen = true, }) {
37
38
  const drag = useRef(null);
38
39
  const { min, max } = limits(viewport);
39
40
  const visibleHeight = expanded ? max : clamp(height, viewport);
41
+ useEffect(() => {
42
+ // Retain the diagram and its camera, but suspend native SVG timelines while
43
+ // hidden. This does not change the user's Pause/Play flow preference.
44
+ panel.current?.querySelectorAll("svg").forEach((svg) => {
45
+ if (open)
46
+ svg.unpauseAnimations();
47
+ else
48
+ svg.pauseAnimations();
49
+ });
50
+ }, [open, root]);
51
+ useEffect(() => {
52
+ const host = document.createElement("jev-devtools-root");
53
+ // The host is the only element exposed to page selectors. The overlay itself
54
+ // lives in the shadow tree, with its own typography and component styles.
55
+ host.style.cssText =
56
+ "all: initial !important; position: fixed !important; inset: 0 !important; pointer-events: none !important; z-index: 2147483000 !important;";
57
+ const shadow = host.attachShadow({ mode: "open" });
58
+ const style = document.createElement("style");
59
+ style.textContent = inspectorStyles;
60
+ shadow.append(style);
61
+ document.body.append(host);
62
+ setRoot(shadow);
63
+ return () => host.remove();
64
+ }, []);
40
65
  useEffect(() => {
41
66
  const resize = () => setViewport(viewportHeight());
42
67
  window.addEventListener("resize", resize);
@@ -57,16 +82,18 @@ export function JevDrawer({ children, defaultOpen = true, }) {
57
82
  useEffect(() => {
58
83
  if (interacted.current)
59
84
  (open ? closeButton.current : trigger.current)?.focus();
60
- }, [open]);
85
+ }, [open, root]);
61
86
  const toggle = (value) => {
62
87
  interacted.current = true;
63
- setOpen(value);
88
+ onOpenChange(value);
64
89
  };
65
90
  const resizeTo = (value) => {
66
91
  setExpanded(false);
67
92
  setHeight(clamp(value, viewport));
68
93
  };
69
- return createPortal(_jsxs(_Fragment, { children: [_jsxs("button", { ref: trigger, className: "jev-devtools jev-drawer-trigger", hidden: open, "aria-label": "Open Jev DevTools", "aria-expanded": open, "aria-controls": id, onClick: () => toggle(true), children: [_jsx(TypeSafeMark, {}), " Jev DevTools"] }), _jsxs("section", { ref: panel, id: id, className: "jev-devtools jev-drawer", hidden: !open, "aria-label": "Jev DevTools", style: { height: visibleHeight }, onKeyDown: (event) => {
94
+ if (!root)
95
+ return null;
96
+ return createPortal(_jsxs(_Fragment, { children: [_jsxs("button", { ref: trigger, className: "jev-devtools jev-drawer-trigger", hidden: open, "aria-label": "Open Jev DevTools", "aria-expanded": open, "aria-controls": id, onClick: () => toggle(true), children: [_jsx(TypeSafeMark, {}), " Jev DevTools"] }), _jsxs("div", { ref: panel, id: id, className: "jev-devtools jev-drawer", hidden: !open, role: "region", "aria-label": "Jev DevTools", style: { height: visibleHeight }, onKeyDown: (event) => {
70
97
  if (event.key === "Escape" && !event.defaultPrevented) {
71
98
  event.stopPropagation();
72
99
  toggle(false);
@@ -107,5 +134,5 @@ export function JevDrawer({ children, defaultOpen = true, }) {
107
134
  event.preventDefault();
108
135
  resizeTo(next);
109
136
  }
110
- }, children: _jsx("span", {}) }), _jsxs("header", { className: "jev-drawer-header", children: [_jsxs("strong", { children: [_jsx(TypeSafeMark, {}), "Jev DevTools"] }), _jsxs("div", { children: [_jsx("button", { "aria-label": expanded ? "Restore Jev drawer" : "Expand Jev drawer", "aria-pressed": expanded, onClick: () => setExpanded(!expanded), children: expanded ? "Restore" : "Expand" }), _jsx("button", { ref: closeButton, className: "jev-drawer-close", "aria-label": "Close Jev DevTools", title: "Close (Esc)", onClick: () => toggle(false), children: "\u00D7" })] })] }), _jsx("div", { className: "jev-drawer-content", children: children })] })] }), document.body);
137
+ }, children: _jsx("span", {}) }), _jsxs("header", { className: "jev-drawer-header", children: [_jsxs("strong", { children: [_jsx(TypeSafeMark, {}), "Jev DevTools"] }), _jsxs("div", { children: [_jsx("button", { "aria-label": expanded ? "Restore Jev drawer" : "Expand Jev drawer", "aria-pressed": expanded, onClick: () => setExpanded(!expanded), children: expanded ? "Restore" : "Expand" }), _jsx("button", { ref: closeButton, className: "jev-drawer-close", "aria-label": "Close Jev DevTools", title: "Close (Esc)", onClick: () => toggle(false), children: "\u00D7" })] })] }), _jsx("div", { className: "jev-drawer-content", children: children })] })] }), root);
111
138
  }
@@ -0,0 +1,5 @@
1
+ import type { Trace } from "./protocol.js";
2
+ /** Variable-height rows retain full answers; only offscreen DOM is omitted. */
3
+ export declare function Questions({ trace }: {
4
+ trace: Trace;
5
+ }): import("react").JSX.Element;
@@ -0,0 +1,174 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { memo, useCallback, useEffect, useLayoutEffect, useId, useMemo, useRef, useState, } from "react";
3
+ import { defaultRangeExtractor, measureElement, observeElementRect, useVirtualizer, } from "@tanstack/react-virtual";
4
+ import { Answer, findImage, JsonBlock } from "./TraceContent.js";
5
+ const QuestionContent = memo(function QuestionContent({ name, question, answer, image, legendOpen, onLegendChange, }) {
6
+ return (_jsxs(_Fragment, { children: [_jsxs("header", { children: [_jsx("h3", { children: name }), _jsx("span", { className: "type", children: question.type })] }), _jsx(JsonBlock, { value: question }), _jsx(Answer, { answer: answer, image: image, legendOpen: legendOpen, onLegendToggle: (open) => onLegendChange(name, open) })] }));
7
+ });
8
+ /** Variable-height rows retain full answers; only offscreen DOM is omitted. */
9
+ export function Questions({ trace }) {
10
+ const helpId = useId();
11
+ const entries = useMemo(() => Object.entries(trace.request.questions ?? {}).filter(([, q]) => q && typeof q === "object" && typeof q.type === "string"), [trace.request.questions]);
12
+ const image = useMemo(() => findImage(trace.request.state), [trace.request.state]);
13
+ const viewport = useRef(null);
14
+ const sizes = useRef(new Map());
15
+ const [seeking, setSeekingState] = useState(false);
16
+ const seekingValue = useRef(false);
17
+ const setSeeking = useCallback((next) => {
18
+ // A range update may already be queued by the virtualizer. Avoid enqueuing
19
+ // the same preview state on every scroll event alongside that React work.
20
+ if (seekingValue.current === next)
21
+ return;
22
+ seekingValue.current = next;
23
+ setSeekingState(next);
24
+ }, []);
25
+ const scrollSample = useRef(null);
26
+ const scrollIdle = useRef(undefined);
27
+ const explicitJump = useRef(false);
28
+ useEffect(() => () => clearTimeout(scrollIdle.current), []);
29
+ const pendingFocus = useRef(null);
30
+ const [focused, setFocused] = useState(null);
31
+ const [destination, setDestination] = useState(0);
32
+ const [legends, setLegends] = useState(() => new Map());
33
+ const onLegendChange = useCallback((name, open) => {
34
+ setLegends((previous) => previous.get(name) === open
35
+ ? previous
36
+ : new Map(previous).set(name, open));
37
+ }, []);
38
+ const getItemKey = useCallback((index) => entries[index][0], [entries]);
39
+ const rangeExtractor = useCallback((range) => {
40
+ const indices = defaultRangeExtractor(range);
41
+ // Never remove a focused control when it scrolls outside the visible range.
42
+ if (focused !== null &&
43
+ focused < entries.length &&
44
+ !indices.includes(focused))
45
+ indices.push(focused);
46
+ return indices.sort((a, b) => a - b);
47
+ }, [focused, entries.length]);
48
+ const virtualizer = useVirtualizer({
49
+ count: entries.length,
50
+ getScrollElement: () => viewport.current,
51
+ getItemKey,
52
+ estimateSize: () => 600,
53
+ overscan: 1,
54
+ rangeExtractor,
55
+ directDomUpdates: true,
56
+ // A hidden drawer reports zero sizes. Retain its measurements and scroll
57
+ // range instead of collapsing rows or resetting the reader's position.
58
+ observeElementRect: (instance, callback) => observeElementRect(instance, (rect) => {
59
+ if (rect.width > 0 && rect.height > 0)
60
+ callback(rect);
61
+ }),
62
+ measureElement: (element, entry, instance) => {
63
+ const key = element.dataset.questionKey;
64
+ // A fast-scroll preview reserves the last measured height. Never replace
65
+ // the real measurement cache with its temporary placeholder geometry.
66
+ if (element.dataset.preview === "true")
67
+ return sizes.current.get(key) || 600;
68
+ const height = measureElement(element, entry, instance);
69
+ if (height > 0)
70
+ sizes.current.set(key, height);
71
+ return height || sizes.current.get(key) || 600;
72
+ },
73
+ });
74
+ const items = virtualizer.getVirtualItems();
75
+ const goTo = useCallback((index, focus = false) => {
76
+ const next = Math.max(0, Math.min(entries.length - 1, index));
77
+ explicitJump.current = true;
78
+ setSeeking(false);
79
+ scrollSample.current = null;
80
+ setDestination(next);
81
+ if (focus) {
82
+ pendingFocus.current = next;
83
+ setFocused(next);
84
+ }
85
+ virtualizer.scrollToIndex(next, { align: "start" });
86
+ }, [entries.length, virtualizer, setSeeking]);
87
+ useLayoutEffect(() => {
88
+ if (pendingFocus.current === null)
89
+ return;
90
+ const card = viewport.current?.querySelector(`[data-index="${pendingFocus.current}"] article`);
91
+ if (card) {
92
+ card.focus({ preventScroll: true });
93
+ pendingFocus.current = null;
94
+ }
95
+ });
96
+ // Scroll-range updates must not rebuild the full question picker.
97
+ const navigation = useMemo(() => (_jsxs(_Fragment, { children: [_jsxs("div", { className: "questions-navigation", children: [_jsxs("label", { children: ["Jump to question", _jsx("select", { "aria-label": "Jump to question", value: Math.min(destination, entries.length - 1), onChange: (event) => goTo(Number(event.target.value)), children: entries.map(([name, q], index) => (_jsxs("option", { value: index, children: [index + 1, ". ", name, " \u00B7 ", q.type] }, name))) })] }), _jsx("button", { "aria-label": "Previous question", disabled: destination === 0, onClick: () => goTo(destination - 1), children: "Previous" }), _jsx("button", { "aria-label": "Next question", disabled: destination >= entries.length - 1, onClick: () => goTo(destination + 1), children: "Next" }), _jsxs("span", { children: [entries.length, " question", entries.length === 1 ? "" : "s"] })] }), _jsx("p", { className: "questions-help muted", id: helpId, children: "\u2191 / \u2193 previous / next \u00B7 Home / End first / last when the list is focused." })] })), [entries, destination, goTo, helpId]);
98
+ if (!entries.length)
99
+ return _jsx("p", { className: "muted", children: "No questions available in this capture." });
100
+ return (_jsxs("div", { className: "questions-list", children: [navigation, _jsx("div", { className: "questions-viewport", ref: viewport, tabIndex: 0, role: "region", "aria-label": "Questions and answers", "aria-describedby": helpId, onScroll: (event) => {
101
+ const offset = event.currentTarget.scrollTop;
102
+ const time = performance.now();
103
+ const previous = scrollSample.current;
104
+ clearTimeout(scrollIdle.current);
105
+ scrollIdle.current = setTimeout(() => {
106
+ scrollSample.current = null;
107
+ explicitJump.current = false;
108
+ setSeeking(false);
109
+ }, 120);
110
+ if (explicitJump.current)
111
+ return;
112
+ if (!previous) {
113
+ scrollSample.current = { offset, time, fastDirection: 0 };
114
+ return;
115
+ }
116
+ const elapsed = time - previous.time;
117
+ if (elapsed < 8)
118
+ return;
119
+ const velocity = (Math.abs(offset - previous.offset) / elapsed) * 1000;
120
+ // A jump across most of the list is a destination change, not a
121
+ // scan. Measurement corrections around its endpoints can otherwise
122
+ // look like additional high-speed samples.
123
+ // Read cached geometry; querying scrollHeight here can force layout
124
+ // immediately after the virtualizer has mounted a new row.
125
+ const viewportHeight = virtualizer.scrollRect?.height || 0;
126
+ const jump = Math.abs(offset - previous.offset) >
127
+ Math.max(viewportHeight, (virtualizer.getTotalSize() - viewportHeight) / 2);
128
+ const fastDirection = !jump && velocity > 4000 ? Math.sign(offset - previous.offset) : 0;
129
+ scrollSample.current = { offset, time, fastDirection };
130
+ // Two fast samples in the same direction distinguish continuous
131
+ // scanning from an isolated jump. A lower exit threshold prevents
132
+ // flickering as the reader slows down.
133
+ if (jump)
134
+ setSeeking(false);
135
+ else if (fastDirection && fastDirection === previous.fastDirection)
136
+ setSeeking(true);
137
+ else if (velocity < 1800)
138
+ setSeeking(false);
139
+ }, onFocusCapture: (event) => {
140
+ const row = event.target.closest("[data-index]");
141
+ if (row) {
142
+ const index = Number(row.dataset.index);
143
+ setFocused(index);
144
+ setDestination(index);
145
+ }
146
+ }, onBlurCapture: (event) => {
147
+ if (!event.currentTarget.contains(event.relatedTarget))
148
+ setFocused(null);
149
+ }, onKeyDown: (event) => {
150
+ // Inputs, disclosures and scrollable JSON keep their native keys.
151
+ if (event.target !== event.currentTarget &&
152
+ !event.target.matches("article"))
153
+ return;
154
+ const next = event.key === "Home"
155
+ ? 0
156
+ : event.key === "End"
157
+ ? entries.length - 1
158
+ : event.key === "ArrowDown"
159
+ ? destination + 1
160
+ : event.key === "ArrowUp"
161
+ ? destination - 1
162
+ : undefined;
163
+ if (next !== undefined) {
164
+ event.preventDefault();
165
+ goTo(next, true);
166
+ }
167
+ }, children: _jsx("div", { role: "list", "aria-label": "Captured questions", className: "questions-spacer", ref: virtualizer.containerRef, children: items.map((item) => {
168
+ const [name, question] = entries[item.index];
169
+ const preview = seeking && focused !== item.index;
170
+ return (_jsx("div", { "data-index": item.index, "data-question-key": name, "data-preview": preview || undefined, style: preview
171
+ ? { height: sizes.current.get(name) || 600 }
172
+ : undefined, ref: virtualizer.measureElement, className: "questions-row", role: "listitem", "aria-posinset": item.index + 1, "aria-setsize": entries.length, children: _jsx("article", { className: "question-node standalone", tabIndex: -1, "aria-busy": preview || undefined, "aria-label": `Question ${item.index + 1}: ${name}`, children: preview ? (_jsxs("div", { className: "question-scroll-preview", children: [_jsxs("header", { children: [_jsx("h3", { children: name }), _jsx("span", { className: "type", children: question.type })] }), _jsx("p", { className: "muted", children: "Pause scrolling to inspect this answer." })] })) : (_jsx(QuestionContent, { name: name, question: question, answer: trace.result?.answers[name], image: image, legendOpen: !!legends.get(name), onLegendChange: onLegendChange })) }) }, item.key));
173
+ }) }) })] }));
174
+ }
@@ -0,0 +1,11 @@
1
+ import type { AnswerView, Json } from "./protocol.js";
2
+ export declare function findImage(value: Json): string | undefined;
3
+ export declare function JsonBlock({ value }: {
4
+ value: unknown;
5
+ }): import("react").JSX.Element;
6
+ export declare function Answer({ answer, image, legendOpen, onLegendToggle, }: {
7
+ answer?: AnswerView;
8
+ image?: string;
9
+ legendOpen: boolean;
10
+ onLegendToggle: (open: boolean) => void;
11
+ }): import("react").JSX.Element;
@@ -0,0 +1,32 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ const pretty = (value) => JSON.stringify(value, null, 2);
3
+ const percent = (value) => `${Math.round(value * 100)}%`;
4
+ export function findImage(value) {
5
+ if (!value || typeof value !== "object")
6
+ return;
7
+ if (!Array.isArray(value) &&
8
+ typeof value.preview === "string" &&
9
+ /^data:image\/(png|jpeg);base64,/.test(value.preview))
10
+ return value.preview;
11
+ for (const child of Object.values(value)) {
12
+ const found = findImage(child);
13
+ if (found)
14
+ return found;
15
+ }
16
+ }
17
+ export function JsonBlock({ value }) {
18
+ return _jsx("pre", { children: pretty(value) });
19
+ }
20
+ export function Answer({ answer, image, legendOpen, onLegendToggle, }) {
21
+ if (!answer)
22
+ return _jsx("p", { className: "muted", children: "No answer received." });
23
+ return (_jsxs("div", { className: "answer", children: [answer.choice !== undefined && (_jsx("strong", { className: "answer-value", children: answer.choice })), answer.score !== undefined && (_jsxs("strong", { className: "answer-value", children: [answer.score.toFixed(2), " ", _jsx("small", { children: "score" })] })), answer.noul !== undefined && (_jsxs(_Fragment, { children: [_jsxs("strong", { className: "answer-value", children: [percent(answer.noul), " ", _jsx("small", { children: "probability of true" })] }), _jsx("div", { className: "bar", children: _jsx("i", { style: { width: percent(answer.noul) } }) })] })), answer.probabilities &&
24
+ Object.entries(answer.probabilities).map(([label, value]) => (_jsxs("div", { className: "probability", children: [_jsxs("div", { children: [_jsx("span", { children: label }), _jsx("b", { children: percent(value) })] }), _jsx("div", { className: "bar", children: _jsx("i", { style: { width: percent(value) } }) })] }, label))), answer.legend && (_jsxs("details", { open: legendOpen, onToggle: (event) => onLegendToggle(event.currentTarget.open), children: [_jsx("summary", { children: "Score legend" }), _jsx(JsonBlock, { value: answer.legend })] })), answer.box && (_jsxs(_Fragment, { children: [_jsxs("div", { className: "box-preview", children: [image ? (_jsx("img", { src: image, alt: "Captured request image" })) : (_jsx("span", { children: "Image preview unavailable" })), _jsx("div", { className: "bounding-box", style: {
25
+ left: percent(answer.box.left),
26
+ top: percent(answer.box.top),
27
+ width: percent(answer.box.right - answer.box.left),
28
+ height: percent(answer.box.bottom - answer.box.top),
29
+ } })] }), _jsx("code", { className: "coordinates", children: Object.entries(answer.box)
30
+ .map(([k, v]) => `${k} ${v.toFixed(2)}`)
31
+ .join(" · ") })] })), answer.confidence !== undefined && (_jsxs("p", { className: "confidence", children: ["Confidence ", _jsx("b", { children: percent(answer.confidence) })] }))] }));
32
+ }
@@ -1,18 +1,20 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useRef, useState } from "react";
2
+ import { useMemo, useState } from "react";
3
+ import { useDiagramViewport } from "./useDiagramViewport.js";
3
4
  import { findImage, flowAnswer, stateText } from "./flow-model.js";
5
+ import { ChoiceProbabilities } from "./ChoiceProbabilities.js";
4
6
  const pct = (v) => `${Math.round(v * 100)}%`;
5
- function Result({ answer, image }) {
7
+ function Result({ answer, image, probabilities, }) {
6
8
  if (!answer)
7
9
  return _jsx("p", { className: "lab-copy", children: "No answer received." });
8
10
  return (_jsxs(_Fragment, { children: [answer.probabilities &&
9
- Object.entries(answer.probabilities).map(([key, value]) => (_jsxs("div", { className: "lab-probability", children: [_jsxs("div", { children: [_jsx("span", { title: typeof answer.legend?.[key] === "string"
11
+ (probabilities ?? Object.entries(answer.probabilities)).map(([key, value]) => (_jsxs("div", { className: "lab-probability", children: [_jsxs("div", { children: [_jsx("span", { title: typeof answer.legend?.[key] === "string"
10
12
  ? answer.legend[key]
11
13
  : key, children: answer.type === "score" && answer.legend?.[key]
12
14
  ? `${key} · ${answer.legend[key]}`
13
- : key }), _jsx("code", { children: pct(value) })] }), _jsx("div", { className: "lab-track", children: _jsx("i", { className: answer.type === "choice" && answer.choice !== key
15
+ : key }), _jsxs("code", { title: `Exact probability: ${value}`, children: [Number((value * 100).toPrecision(6)), "%"] })] }), _jsx("div", { className: "lab-track", children: _jsx("i", { className: answer.type === "choice" && answer.choice !== key
14
16
  ? "lab-alternative"
15
- : "", style: { width: pct(Math.max(0, Math.min(1, value))) } }) })] }, key))), answer.choice !== undefined && (_jsxs("p", { className: "lab-result", children: ["Selected: ", answer.choice] })), answer.score !== undefined && (_jsxs(_Fragment, { children: [_jsxs("p", { className: "lab-result", children: ["Score ", answer.score.toFixed(2)] }), _jsxs("div", { className: "lab-scale", children: [_jsx("span", { style: {
17
+ : "", style: { width: pct(Math.max(0, Math.min(1, value))) } }) })] }, key))), answer.choice !== undefined && (_jsxs("p", { className: "lab-result", title: `Selected: ${answer.choice}`, children: ["Selected: ", answer.choice] })), answer.score !== undefined && (_jsxs(_Fragment, { children: [_jsxs("p", { className: "lab-result", children: ["Score ", answer.score.toFixed(2)] }), _jsxs("div", { className: "lab-scale", children: [_jsx("span", { style: {
16
18
  left: pct(Math.max(0, Math.min(1, answer.score /
17
19
  Math.max(1, Object.keys(answer.probabilities ?? {}).length - 1)))),
18
20
  } }), Object.keys(answer.probabilities ?? {}).map((k) => (_jsx("small", { children: k }, k)))] })] })), answer.noul !== undefined && (_jsxs(_Fragment, { children: [_jsx("strong", { className: "lab-big", children: pct(answer.noul) }), _jsx("p", { className: "lab-small", children: "probability of true" }), _jsx("div", { className: "lab-track", children: _jsx("i", { style: { width: pct(answer.noul) } }) }), _jsxs("div", { className: "lab-endpoints", children: [_jsx("span", { children: "False" }), _jsx("span", { children: "True" })] })] })), answer.box && (_jsxs(_Fragment, { children: [_jsxs("div", { className: "lab-image", children: [image ? (_jsx("img", { src: image, alt: "Captured image with returned Box overlay" })) : (_jsx("p", { children: "Image preview unavailable" })), image && (_jsx("div", { className: "lab-box", style: {
@@ -23,51 +25,110 @@ function Result({ answer, image }) {
23
25
  } }))] }), _jsxs("p", { className: "lab-small lab-coordinates", children: ["left ", answer.box.left.toFixed(2), " \u00B7 top ", answer.box.top.toFixed(2), _jsx("br", {}), "right ", answer.box.right.toFixed(2), " \u00B7 bottom", " ", answer.box.bottom.toFixed(2)] })] })), answer.confidence !== undefined && (_jsxs("p", { className: "lab-small", children: ["Confidence ", pct(answer.confidence)] }))] }));
24
26
  }
25
27
  export function TraceDiagram({ trace }) {
26
- const [selected, setSelected] = useState(""), [zoom, setZoom] = useState(0.8), [flow, setFlow] = useState(true);
27
- const viewport = useRef(null);
28
+ const [selected, setSelected] = useState(""), [flow, setFlow] = useState(true), [extraCategories, setExtraCategories] = useState({}), [entering, setEntering] = useState(null), [probabilityView, setProbabilityView] = useState(null);
28
29
  const entries = Object.entries(trace.request.questions ?? {}).filter(([, q]) => q && typeof q === "object" && typeof q.type === "string"), [key, question] = entries.find(([key]) => key === selected) ??
29
30
  entries[0] ?? ["", undefined];
30
31
  const answer = trace.result?.answers[key], image = findImage(trace.request.state) ||
31
- (trace.request.assets ? findImage(trace.request.assets) : undefined), outputs = flowAnswer(answer);
32
- const resultHeight = Math.max(280, 166 +
33
- Object.keys(answer?.probabilities ?? {}).length * 44 +
32
+ (trace.request.assets ? findImage(trace.request.assets) : undefined), allProbabilities = useMemo(() => Object.entries(answer?.probabilities ?? {}).sort((a, b) => b[1] - a[1]), [answer?.probabilities]);
33
+ const compact = answer?.type === "choice" && allProbabilities.length > 6;
34
+ const shownProbabilities = compact
35
+ ? allProbabilities.slice(0, 3)
36
+ : allProbabilities;
37
+ if (compact &&
38
+ answer.choice !== undefined &&
39
+ !shownProbabilities.some(([label]) => label === answer.choice)) {
40
+ const returned = allProbabilities.find(([label]) => label === answer.choice);
41
+ if (returned)
42
+ shownProbabilities.push(returned);
43
+ }
44
+ if (compact && (extraCategories[key] ?? 0) > 0) {
45
+ const initialLabels = new Set(shownProbabilities.map(([label]) => label));
46
+ shownProbabilities.push(...allProbabilities
47
+ .filter(([label]) => !initialLabels.has(label))
48
+ .slice(0, extraCategories[key] ?? 0));
49
+ }
50
+ const outputs = flowAnswer(compact
51
+ ? { ...answer, probabilities: Object.fromEntries(shownProbabilities) }
52
+ : answer);
53
+ const remaining = compact
54
+ ? allProbabilities.length - shownProbabilities.length
55
+ : 0;
56
+ const spacing = compact ? 112 : 136;
57
+ const showProbabilities = probabilityView === key && answer?.type === "choice";
58
+ const resultHeight = Math.max(280, (compact ? 230 : 166) +
59
+ (compact
60
+ ? shownProbabilities.length
61
+ : Object.keys(answer?.probabilities ?? {}).length) *
62
+ 44 +
34
63
  (answer?.score !== undefined ? 50 : 0), answer?.box ? 390 : 0);
35
- const height = Math.max(520, resultHeight + 90, outputs.length * 136 + 60), ry = Math.max(40, (height - resultHeight) / 2), mid = ry + resultHeight / 2;
36
- const fit = () => {
37
- if (viewport.current)
38
- setZoom(Math.min(1, (viewport.current.clientWidth - 24) / 1120));
39
- };
40
- useEffect(() => {
41
- if (viewport.current)
42
- setZoom(Math.min(1, Math.max(0.75, (viewport.current.clientWidth - 24) / 1120)));
43
- }, []);
64
+ const height = Math.max(520, resultHeight + 90, (outputs.length + (remaining ? 1 : 0)) * spacing + 60), ry = compact ? 40 : Math.max(40, (height - resultHeight) / 2), mid = ry + resultHeight / 2;
65
+ const { viewport, canvas, zoom, zoomBy, fit, preserveCamera } = useDiagramViewport(1120, height, key);
44
66
  if (!question)
45
67
  return _jsx("p", { className: "muted", children: "No questions available in this capture." });
46
68
  const sourcePath = (y) => `M260 ${y} C325 ${y},325 ${mid},390 ${mid}`;
69
+ const enteringDelay = (index) => entering?.question === key && index >= entering.from && index < entering.to
70
+ ? `${(index - entering.from) * 55}ms`
71
+ : undefined;
47
72
  const paths = [
48
73
  { id: "state", d: sourcePath(122), active: true },
49
74
  { id: "question", d: sourcePath(378), active: true },
50
75
  ...outputs.map((output, i) => ({
51
- id: `output-${i}`,
52
- d: `M710 ${mid} C790 ${mid},790 ${92 + i * 136},870 ${92 + i * 136}`,
76
+ id: `output-${key}-${output.label}`,
77
+ delay: enteringDelay(i),
78
+ d: `M710 ${mid} C790 ${mid},790 ${92 + i * spacing},870 ${92 + i * spacing}`,
53
79
  active: output.active,
54
80
  })),
55
81
  ];
56
- return (_jsxs("div", { className: "lab-flow", children: [_jsxs("div", { className: "lab-flow-toolbar", children: [_jsxs("label", { children: ["Question", _jsx("select", { "aria-label": "Diagram question", value: key, onChange: (event) => setSelected(event.target.value), children: entries.map(([name, q]) => (_jsxs("option", { value: name, children: [name, " \u00B7 ", q.type] }, name))) })] }), _jsxs("div", { children: [_jsx("button", { "aria-label": "Zoom diagram out", onClick: () => setZoom((v) => Math.max(0.4, v - 0.1)), children: "\u2212" }), _jsxs("output", { "aria-label": "Diagram zoom", children: [Math.round(zoom * 100), "%"] }), _jsx("button", { "aria-label": "Zoom diagram in", onClick: () => setZoom((v) => Math.min(1.5, v + 0.1)), children: "+" }), _jsx("button", { onClick: fit, children: "Fit" }), _jsx("button", { "aria-pressed": flow, onClick: () => setFlow(!flow), children: flow ? "Pause flow" : "Play flow" })] })] }), _jsx("div", { className: "lab-flow-viewport", ref: viewport, tabIndex: 0, "aria-label": "Jev diagram, scroll to explore", children: _jsx("div", { style: { width: 1120 * zoom, height: height * zoom }, children: _jsxs("div", { className: "lab-flow-canvas", style: { width: 1120, height, transform: `scale(${zoom})` }, children: [_jsxs("svg", { className: "lab-connections", width: "1120", height: height, "aria-hidden": "true", children: [paths.map((path) => (_jsxs("g", { children: [_jsx("path", { d: path.d, className: path.active ? "active" : "" }), flow &&
57
- path.active &&
58
- !["error", "cancelled"].includes(trace.status) && (_jsx("circle", { r: "3", className: "lab-flow-dot", children: _jsx("animateMotion", { dur: "3.2s", repeatCount: "indefinite", path: path.d }) }))] }, path.id))), _jsx("text", { x: "305", y: mid - 65, children: "state" }), _jsx("text", { x: "300", y: mid + 65, children: "question" })] }), _jsxs("article", { className: "lab-card", style: { left: 20, top: 32, width: 240, height: 180 }, children: [_jsx("span", { className: "lab-eyebrow", children: image ? "IMAGE STATE" : "STATE" }), _jsx("h3", { children: "Shared context" }), image ? (_jsx("img", { className: "lab-source-image", src: image, alt: "Captured image state" })) : (_jsx("p", { className: "lab-copy", title: stateText(trace), children: stateText(trace) }))] }), _jsxs("article", { className: "lab-card", style: { left: 20, top: 288, width: 240, height: 180 }, children: [_jsx("span", { className: "lab-eyebrow", children: "INSTRUCTIONS" }), _jsxs("h3", { children: ["Question \u00B7 ", question.type] }), _jsx("p", { className: "lab-copy", title: typeof question.instructions === "string"
59
- ? question.instructions
60
- : JSON.stringify(question.instructions), children: typeof question.instructions === "string"
61
- ? question.instructions
62
- : JSON.stringify(question.instructions) })] }), _jsxs("article", { className: "lab-card lab-answer-card", style: {
63
- left: 390,
64
- top: ry,
65
- width: 320,
66
- minHeight: resultHeight,
67
- }, children: [_jsxs("span", { className: "lab-eyebrow", children: ["JEV \u00B7 ", question.type.toUpperCase()] }), _jsx("h3", { title: key, children: key }), _jsx(Result, { answer: answer, image: image })] }), outputs.map((output, i) => (_jsxs("article", { className: `lab-card lab-output ${output.active ? "" : "lab-idle"}`, style: {
68
- left: 870,
69
- top: 40 + i * 136,
70
- width: 230,
71
- minHeight: 104,
72
- }, children: [_jsx("span", { className: "lab-eyebrow", children: output.eyebrow }), _jsx("h3", { title: output.label, children: output.label }), _jsx("p", { children: output.detail })] }, i)))] }) }) }), _jsxs("p", { className: "muted", children: [entries.length, " parallel question", entries.length === 1 ? "" : "s", " in one request. Connections show data flow, not model timing. Application routes and thresholds are not captured. Full inputs and criteria are in Questions and Context."] })] }));
82
+ return (_jsxs("div", { className: `lab-flow ${compact ? "lab-flow-compact" : ""}`, children: [_jsxs("div", { className: "lab-flow-toolbar", children: [_jsxs("label", { children: ["Question", _jsx("select", { "aria-label": "Diagram question", value: key, onChange: (event) => {
83
+ setEntering(null);
84
+ setSelected(event.target.value);
85
+ }, children: entries.map(([name, q]) => (_jsxs("option", { value: name, children: [name, " \u00B7 ", q.type] }, name))) })] }), _jsxs("div", { children: [answer?.type === "choice" && (_jsx("button", { "aria-pressed": showProbabilities, onClick: () => {
86
+ setEntering(null);
87
+ setProbabilityView(showProbabilities ? null : key);
88
+ }, children: showProbabilities
89
+ ? "Back to overview"
90
+ : `All probabilities (${allProbabilities.length})` })), !showProbabilities && (_jsxs(_Fragment, { children: [compact && (_jsx("button", { disabled: (extraCategories[key] ?? 0) === 0, onClick: () => {
91
+ preserveCamera();
92
+ setEntering(null);
93
+ setExtraCategories((counts) => ({ ...counts, [key]: 0 }));
94
+ }, children: "Show fewer" })), _jsx("button", { "aria-label": "Zoom diagram out", disabled: zoom <= 10, onClick: () => zoomBy(1 / 1.2), children: "\u2212" }), _jsxs("output", { "aria-label": "Diagram zoom", children: [zoom, "%"] }), _jsx("button", { "aria-label": "Zoom diagram in", disabled: zoom >= 400, onClick: () => zoomBy(1.2), children: "+" }), _jsx("button", { onClick: fit, children: "Fit" }), _jsx("button", { "aria-pressed": flow, onClick: () => setFlow(!flow), children: flow ? "Pause flow" : "Play flow" })] }))] })] }), showProbabilities && answer && (_jsx(ChoiceProbabilities, { answer: answer }, key)), _jsx("div", { hidden: showProbabilities, className: "lab-flow-viewport", ref: viewport, tabIndex: 0, role: "group", "aria-label": "Jev diagram. Drag to pan, scroll or pinch to zoom. Arrow keys pan, plus and minus zoom, zero fits.", children: _jsxs("div", { ref: canvas, className: "lab-flow-canvas", style: { width: 1120, height }, children: [_jsxs("svg", { className: "lab-connections", width: "1120", height: height, "aria-hidden": "true", children: [paths.map((path) => (_jsxs("g", { children: [_jsx("path", { d: path.d, pathLength: 1, className: `${path.active ? "active" : ""} ${"delay" in path && path.delay !== undefined ? "lab-branch-enter" : ""}`, style: {
95
+ animationDelay: "delay" in path ? path.delay : undefined,
96
+ } }), flow &&
97
+ !showProbabilities &&
98
+ path.active &&
99
+ !["error", "cancelled"].includes(trace.status) && (_jsx("circle", { r: "3", className: "lab-flow-dot", children: _jsx("animateMotion", { dur: "3.2s", repeatCount: "indefinite", path: path.d }) }))] }, path.id))), _jsx("text", { x: "305", y: mid - 65, children: "state" }), _jsx("text", { x: "300", y: mid + 65, children: "question" })] }), _jsxs("article", { className: "lab-card", style: { left: 20, top: 32, width: 240, height: 180 }, children: [_jsx("span", { className: "lab-eyebrow", children: image ? "IMAGE STATE" : "STATE" }), _jsx("h3", { children: "Shared context" }), image ? (_jsx("img", { className: "lab-source-image", src: image, alt: "Captured image state" })) : (_jsx("p", { className: "lab-copy", title: stateText(trace), children: stateText(trace) }))] }), _jsxs("article", { className: "lab-card", style: { left: 20, top: 288, width: 240, height: 180 }, children: [_jsx("span", { className: "lab-eyebrow", children: "INSTRUCTIONS" }), _jsxs("h3", { children: ["Question \u00B7 ", question.type] }), _jsx("p", { className: "lab-copy", title: typeof question.instructions === "string"
100
+ ? question.instructions
101
+ : JSON.stringify(question.instructions), children: typeof question.instructions === "string"
102
+ ? question.instructions
103
+ : JSON.stringify(question.instructions) })] }), _jsxs("article", { className: "lab-card lab-answer-card", style: {
104
+ left: 390,
105
+ top: ry,
106
+ width: 320,
107
+ minHeight: resultHeight,
108
+ }, children: [_jsxs("span", { className: "lab-eyebrow", children: ["JEV \u00B7 ", question.type.toUpperCase()] }), _jsx("h3", { title: key, children: key }), compact && (_jsxs("p", { className: "lab-small", children: ["Showing ", shownProbabilities.length, " of ", allProbabilities.length, " ", "probabilities"] })), _jsx(Result, { answer: answer, image: image, probabilities: compact ? shownProbabilities : undefined })] }), outputs.map((output, i) => (_jsxs("article", { className: `lab-card lab-output ${output.active ? "" : "lab-idle"} ${enteringDelay(i) !== undefined ? "lab-card-enter" : ""}`, onAnimationEnd: () => {
109
+ if (entering?.question === key && i === entering.to - 1)
110
+ setEntering(null);
111
+ }, style: {
112
+ left: 870,
113
+ top: 40 + i * spacing,
114
+ width: 230,
115
+ minHeight: compact ? 96 : 104,
116
+ animationDelay: enteringDelay(i),
117
+ }, children: [_jsx("span", { className: "lab-eyebrow", children: output.eyebrow }), _jsx("h3", { title: output.label, children: output.label }), _jsx("p", { children: output.detail })] }, `${key}-${output.label}`))), remaining > 0 && (_jsxs("article", { className: "lab-card lab-choice-remainder", style: {
118
+ left: 870,
119
+ top: 40 + outputs.length * spacing,
120
+ width: 230,
121
+ minHeight: 96,
122
+ }, children: [_jsx("span", { className: "lab-eyebrow", children: "MORE CATEGORIES" }), _jsx("button", { onClick: () => {
123
+ preserveCamera();
124
+ setEntering({
125
+ question: key,
126
+ from: outputs.length,
127
+ to: outputs.length + Math.min(3, remaining),
128
+ });
129
+ setExtraCategories((counts) => ({
130
+ ...counts,
131
+ [key]: (counts[key] ?? 0) + 3,
132
+ }));
133
+ }, children: "Show More" }), _jsxs("p", { className: "lab-small", children: [remaining, " remaining \u00B7 Add ", Math.min(3, remaining)] })] }))] }) }), !showProbabilities && (_jsxs("p", { className: "muted lab-flow-caption", children: [_jsx("span", { className: "lab-gesture-hint", children: "Drag to pan \u00B7 Scroll or pinch to zoom \u00B7 0 to fit" }), entries.length, " parallel question", entries.length === 1 ? "" : "s", " in one request. Connections show data flow, not model timing. Application routes and thresholds are not captured. Full inputs and criteria are in Questions and Context."] }))] }));
73
134
  }
@@ -0,0 +1,16 @@
1
+ export type Point = {
2
+ x: number;
3
+ y: number;
4
+ };
5
+ export type Camera = Point & {
6
+ scale: number;
7
+ };
8
+ export type Size = {
9
+ width: number;
10
+ height: number;
11
+ };
12
+ export declare const minScale = 0.1;
13
+ export declare const maxScale = 4;
14
+ export declare function fitCamera(view: Size, content: Size): Camera;
15
+ /** Keep the content under `from` under `to`, including while a pinch moves. */
16
+ export declare function zoomCamera(camera: Camera, factor: number, from: Point, to?: Point): Camera;
@@ -0,0 +1,20 @@
1
+ export const minScale = 0.1;
2
+ export const maxScale = 4;
3
+ const limit = (scale) => Math.max(minScale, Math.min(maxScale, scale));
4
+ export function fitCamera(view, content) {
5
+ const scale = limit(Math.min((view.width - 48) / content.width, (view.height - 48) / content.height, 1));
6
+ return {
7
+ x: (view.width - content.width * scale) / 2,
8
+ y: (view.height - content.height * scale) / 2,
9
+ scale,
10
+ };
11
+ }
12
+ /** Keep the content under `from` under `to`, including while a pinch moves. */
13
+ export function zoomCamera(camera, factor, from, to = from) {
14
+ const scale = limit(camera.scale * factor);
15
+ return {
16
+ x: to.x - ((from.x - camera.x) * scale) / camera.scale,
17
+ y: to.y - ((from.y - camera.y) * scale) / camera.scale,
18
+ scale,
19
+ };
20
+ }
@@ -17,7 +17,7 @@ export function flowAnswer(answer) {
17
17
  if (answer.type === "choice")
18
18
  return Object.entries(answer.probabilities ?? {}).map(([label, probability]) => ({
19
19
  label,
20
- detail: `${Math.round(probability * 100)}% probability`,
20
+ detail: `${Number((probability * 100).toPrecision(6))}% probability`,
21
21
  active: label === answer.choice,
22
22
  eyebrow: label === answer.choice ? "RETURNED CHOICE" : "ALTERNATIVE",
23
23
  }));
@@ -1,4 +1,4 @@
1
- export type { Trace, Snapshot, Json, AnswerView, QuestionView, } from "./protocol.js";
1
+ export type { Trace, Snapshot, StreamSnapshot, TraceDelta, Json, AnswerView, QuestionView, } from "./protocol.js";
2
2
  export type JevDevtoolsProps = {
3
3
  endpoint?: string;
4
4
  /** Opt in explicitly, normally with import.meta.env.DEV. */
@@ -0,0 +1,2 @@
1
+ /** Generated from inspector.css during the package build. */
2
+ export declare const inspectorStyles: string;