@aryasaatvik/motel 0.3.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.
Files changed (99) hide show
  1. package/AGENTS.md +180 -0
  2. package/LICENSE +21 -0
  3. package/README.md +137 -0
  4. package/package.json +119 -0
  5. package/skills/motel-debug/SKILL.md +203 -0
  6. package/skills/motel-debug/references/effect.md +38 -0
  7. package/src/App.tsx +414 -0
  8. package/src/StartupGate.test.ts +80 -0
  9. package/src/StartupGate.tsx +263 -0
  10. package/src/cli.ts +205 -0
  11. package/src/config.ts +45 -0
  12. package/src/daemon.test.ts +547 -0
  13. package/src/daemon.ts +486 -0
  14. package/src/domain.test.ts +62 -0
  15. package/src/domain.ts +291 -0
  16. package/src/httpApi.ts +391 -0
  17. package/src/httpListPolicy.test.ts +76 -0
  18. package/src/httpListPolicy.ts +129 -0
  19. package/src/index.tsx +25 -0
  20. package/src/instructions.ts +72 -0
  21. package/src/launchAgent.test.ts +430 -0
  22. package/src/launchAgent.ts +462 -0
  23. package/src/localServer.ts +568 -0
  24. package/src/locator.ts +138 -0
  25. package/src/mcp.ts +433 -0
  26. package/src/motel.test.ts +176 -0
  27. package/src/motel.ts +88 -0
  28. package/src/motelClient.ts +353 -0
  29. package/src/opentui-jsx.d.ts +11 -0
  30. package/src/otlp.test.ts +65 -0
  31. package/src/otlp.ts +162 -0
  32. package/src/otlpProtobuf.ts +35 -0
  33. package/src/queryFilters.ts +39 -0
  34. package/src/registry.ts +115 -0
  35. package/src/runtime.ts +40 -0
  36. package/src/server.ts +11 -0
  37. package/src/services/AsyncIngest.ts +82 -0
  38. package/src/services/LogQueryService.ts +29 -0
  39. package/src/services/TelemetryQuery.ts +62 -0
  40. package/src/services/TelemetryStore.ts +2554 -0
  41. package/src/services/TraceQueryService.ts +43 -0
  42. package/src/services/ingestRpc.ts +39 -0
  43. package/src/services/queryRpc.ts +15 -0
  44. package/src/services/telemetryQueryWorker.ts +32 -0
  45. package/src/services/telemetryWorker.ts +59 -0
  46. package/src/startupBench.ts +19 -0
  47. package/src/startupLifecycle.ts +57 -0
  48. package/src/storybook/aiChatStory.tsx +244 -0
  49. package/src/storybook/fixtures/errorState.ts +44 -0
  50. package/src/storybook/fixtures/imagePaste.ts +34 -0
  51. package/src/storybook/fixtures/index.ts +62 -0
  52. package/src/storybook/fixtures/kitchenSink.ts +148 -0
  53. package/src/storybook/fixtures/rawPrompt.ts +15 -0
  54. package/src/storybook/fixtures/short.ts +27 -0
  55. package/src/storybook/fixtures/toolHeavy.ts +65 -0
  56. package/src/telemetry.test.ts +1053 -0
  57. package/src/ui/AiChatView.tsx +308 -0
  58. package/src/ui/AttrFilterModal.tsx +120 -0
  59. package/src/ui/ServiceLogs.tsx +115 -0
  60. package/src/ui/SpanContentView.tsx +184 -0
  61. package/src/ui/SpanDetail.tsx +215 -0
  62. package/src/ui/SpanDetailFull.tsx +224 -0
  63. package/src/ui/SpanDetailPane.tsx +90 -0
  64. package/src/ui/TraceDetailsPane.tsx +175 -0
  65. package/src/ui/TraceList.tsx +254 -0
  66. package/src/ui/Waterfall.tsx +389 -0
  67. package/src/ui/aiChatModel.test.ts +391 -0
  68. package/src/ui/aiChatModel.ts +773 -0
  69. package/src/ui/aiState.ts +59 -0
  70. package/src/ui/app/TraceListPane.tsx +39 -0
  71. package/src/ui/app/TraceWorkspace.tsx +452 -0
  72. package/src/ui/app/useAppLayout.ts +101 -0
  73. package/src/ui/app/useTraceScreenData.ts +590 -0
  74. package/src/ui/atoms.ts +131 -0
  75. package/src/ui/cachedLoader.test.ts +23 -0
  76. package/src/ui/cachedLoader.ts +60 -0
  77. package/src/ui/filterParser.test.ts +56 -0
  78. package/src/ui/filterParser.ts +45 -0
  79. package/src/ui/format.ts +128 -0
  80. package/src/ui/loaders.ts +101 -0
  81. package/src/ui/persistence.ts +41 -0
  82. package/src/ui/primitives.tsx +198 -0
  83. package/src/ui/state.ts +6 -0
  84. package/src/ui/theme.ts +160 -0
  85. package/src/ui/traceDetailsWidth.repro.test.ts +126 -0
  86. package/src/ui/traceSortNav.repro.seed.ts +62 -0
  87. package/src/ui/traceSortNav.repro.test.ts +231 -0
  88. package/src/ui/useAttrFilterPicker.ts +89 -0
  89. package/src/ui/useKeyboardNav.ts +1023 -0
  90. package/src/ui/waterfallFilter.test.ts +84 -0
  91. package/src/ui/waterfallFilter.ts +59 -0
  92. package/src/ui/waterfallModel.ts +130 -0
  93. package/src/ui/waterfallNav.repro.seed.ts +86 -0
  94. package/src/ui/waterfallNav.repro.test.ts +271 -0
  95. package/src/ui/waterfallNav.test.ts +453 -0
  96. package/src/ui/waterfallNav.ts +75 -0
  97. package/web/dist/assets/index-B01z9BaO.css +2 -0
  98. package/web/dist/assets/index-CYlBaY9O.js +22 -0
  99. package/web/dist/index.html +13 -0
@@ -0,0 +1,453 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import type { TraceSpanItem } from "../domain.ts"
3
+ import {
4
+ findFirstChildIndex,
5
+ findParentIndex,
6
+ getWaterfallLayout,
7
+ getWaterfallSuffixMetrics,
8
+ getVisibleSpans,
9
+ } from "./waterfallModel.ts"
10
+ import { resolveCollapseStep } from "./waterfallNav.ts"
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Test helpers
14
+ // ---------------------------------------------------------------------------
15
+
16
+ const mkSpan = (spanId: string, depth: number, parentSpanId: string | null): TraceSpanItem => ({
17
+ spanId,
18
+ parentSpanId,
19
+ serviceName: "svc",
20
+ scopeName: null,
21
+ kind: null,
22
+ operationName: spanId,
23
+ startTime: new Date(0),
24
+ isRunning: false,
25
+ durationMs: 1,
26
+ status: "ok",
27
+ depth,
28
+ tags: {},
29
+ warnings: [],
30
+ events: [],
31
+ })
32
+
33
+ /**
34
+ * A reusable, depth-first ordered span tree.
35
+ *
36
+ * root (0)
37
+ * ├─ a (1)
38
+ * │ ├─ a1 (2)
39
+ * │ └─ a2 (2)
40
+ * ├─ b (1)
41
+ * │ └─ b1 (2)
42
+ * │ └─ b1a (3)
43
+ * └─ c (1)
44
+ */
45
+ const buildTree = (): readonly TraceSpanItem[] => [
46
+ mkSpan("root", 0, null),
47
+ mkSpan("a", 1, "root"),
48
+ mkSpan("a1", 2, "a"),
49
+ mkSpan("a2", 2, "a"),
50
+ mkSpan("b", 1, "root"),
51
+ mkSpan("b1", 2, "b"),
52
+ mkSpan("b1a", 3, "b1"),
53
+ mkSpan("c", 1, "root"),
54
+ ]
55
+
56
+ const idsOf = (spans: readonly TraceSpanItem[]) => spans.map((s) => s.spanId)
57
+ const indexOfId = (spans: readonly TraceSpanItem[], id: string) => spans.findIndex((s) => s.spanId === id)
58
+
59
+ // Convenience: run resolveCollapseStep with the selection identified by spanId.
60
+ const step = (
61
+ spans: readonly TraceSpanItem[],
62
+ collapsed: ReadonlySet<string>,
63
+ selectedSpanId: string | null,
64
+ direction: "left" | "right",
65
+ ) => {
66
+ const visible = getVisibleSpans(spans, collapsed)
67
+ const selectedIndex = selectedSpanId === null ? -1 : visible.findIndex((s) => s.spanId === selectedSpanId)
68
+ const out = resolveCollapseStep({
69
+ spans,
70
+ collapsed,
71
+ selectedIndex: selectedIndex < 0 ? null : selectedIndex,
72
+ direction,
73
+ })
74
+ const newVisible = getVisibleSpans(spans, out.collapsed)
75
+ return {
76
+ collapsed: out.collapsed,
77
+ selectedIndex: out.selectedIndex,
78
+ selectedSpanId: out.selectedIndex !== null ? newVisible[out.selectedIndex]?.spanId ?? null : null,
79
+ visibleIds: idsOf(newVisible),
80
+ }
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // getVisibleSpans
85
+ // ---------------------------------------------------------------------------
86
+
87
+ describe("getVisibleSpans", () => {
88
+ it("returns the full list when nothing is collapsed", () => {
89
+ const spans = buildTree()
90
+ expect(idsOf(getVisibleSpans(spans, new Set()))).toEqual([
91
+ "root", "a", "a1", "a2", "b", "b1", "b1a", "c",
92
+ ])
93
+ })
94
+
95
+ it("hides direct children of a collapsed node", () => {
96
+ const spans = buildTree()
97
+ expect(idsOf(getVisibleSpans(spans, new Set(["a"])))).toEqual([
98
+ "root", "a", "b", "b1", "b1a", "c",
99
+ ])
100
+ })
101
+
102
+ it("hides transitive descendants of a collapsed node", () => {
103
+ const spans = buildTree()
104
+ expect(idsOf(getVisibleSpans(spans, new Set(["b"])))).toEqual([
105
+ "root", "a", "a1", "a2", "b", "c",
106
+ ])
107
+ })
108
+
109
+ it("collapsing a leaf changes nothing visually (no children to hide)", () => {
110
+ const spans = buildTree()
111
+ expect(idsOf(getVisibleSpans(spans, new Set(["c"])))).toEqual(idsOf(spans))
112
+ })
113
+
114
+ it("handles multiple collapsed sibling subtrees", () => {
115
+ const spans = buildTree()
116
+ expect(idsOf(getVisibleSpans(spans, new Set(["a", "b"])))).toEqual([
117
+ "root", "a", "b", "c",
118
+ ])
119
+ })
120
+
121
+ it("collapsing root hides everything but root", () => {
122
+ const spans = buildTree()
123
+ expect(idsOf(getVisibleSpans(spans, new Set(["root"])))).toEqual(["root"])
124
+ })
125
+
126
+ it("collapsing a node and its descendant is idempotent", () => {
127
+ const spans = buildTree()
128
+ expect(idsOf(getVisibleSpans(spans, new Set(["b", "b1"])))).toEqual([
129
+ "root", "a", "a1", "a2", "b", "c",
130
+ ])
131
+ })
132
+ })
133
+
134
+ describe("getWaterfallSuffixMetrics", () => {
135
+ it("uses the widest visible duration as the shared suffix width", () => {
136
+ const spans = [
137
+ { spanId: "a", durationMs: 1 },
138
+ { spanId: "b", durationMs: 57_000 },
139
+ { spanId: "c", durationMs: 120 },
140
+ ]
141
+ const metrics = getWaterfallSuffixMetrics(spans)
142
+ // `120ms` = 5 is the widest
143
+ expect(metrics.maxDurationWidth).toBe(5)
144
+ expect(metrics.suffixWidth).toBe(5)
145
+ })
146
+
147
+ it("layout reserves the suffix once and leaves the rest for the bar", () => {
148
+ const contentWidth = 72
149
+ const metrics = getWaterfallSuffixMetrics(
150
+ [{ spanId: "a", durationMs: 57_000 }, { spanId: "b", durationMs: 1 }],
151
+ )
152
+ const { labelMaxWidth, barWidth } = getWaterfallLayout(contentWidth, metrics.suffixWidth)
153
+ // label + 1 (gap before bar) + bar + 1 (gap before suffix) + suffix = contentWidth
154
+ expect(labelMaxWidth + 1 + barWidth + 1 + metrics.suffixWidth).toBe(contentWidth)
155
+ })
156
+
157
+ it("layout fits inside contentWidth at narrow widths without overflow", () => {
158
+ // Regression guard: a prior `max(6, ...)` floor on barWidth caused
159
+ // the total row width to exceed contentWidth at narrow panes,
160
+ // which in turn made OpenTUI's truncate add "..." suffixes
161
+ // across the right edge. Every width in this sweep must satisfy
162
+ // label + 1 + bar + 1 + suffix == contentWidth.
163
+ for (let contentWidth = 14; contentWidth <= 120; contentWidth++) {
164
+ for (const suffixWidth of [3, 5, 7]) {
165
+ const { labelMaxWidth, barWidth } = getWaterfallLayout(contentWidth, suffixWidth)
166
+ expect(labelMaxWidth + 1 + barWidth + 1 + suffixWidth).toBe(contentWidth)
167
+ expect(barWidth).toBeGreaterThanOrEqual(1)
168
+ expect(labelMaxWidth).toBeGreaterThanOrEqual(4)
169
+ }
170
+ }
171
+ })
172
+ })
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // findParentIndex
176
+ // ---------------------------------------------------------------------------
177
+
178
+ describe("findParentIndex", () => {
179
+ it("returns null for the root span", () => {
180
+ const spans = buildTree()
181
+ expect(findParentIndex(spans, indexOfId(spans, "root"))).toBeNull()
182
+ })
183
+
184
+ it("returns the immediate parent index in the same list", () => {
185
+ const spans = buildTree()
186
+ expect(findParentIndex(spans, indexOfId(spans, "a1"))).toBe(indexOfId(spans, "a"))
187
+ expect(findParentIndex(spans, indexOfId(spans, "b1a"))).toBe(indexOfId(spans, "b1"))
188
+ expect(findParentIndex(spans, indexOfId(spans, "c"))).toBe(indexOfId(spans, "root"))
189
+ })
190
+
191
+ it("works against a filtered (visible) list — parent is the nearest shallower ancestor before index", () => {
192
+ const spans = buildTree()
193
+ const visible = getVisibleSpans(spans, new Set(["b1"]))
194
+ // visible: root, a, a1, a2, b, b1, c
195
+ expect(findParentIndex(visible, indexOfId(visible, "b1"))).toBe(indexOfId(visible, "b"))
196
+ })
197
+
198
+ it("returns null on out-of-range index instead of crashing", () => {
199
+ const spans = buildTree()
200
+ expect(findParentIndex(spans, 999)).toBeNull()
201
+ expect(findParentIndex(spans, -1)).toBeNull()
202
+ })
203
+
204
+ it("returns null on empty input", () => {
205
+ expect(findParentIndex([], 0)).toBeNull()
206
+ })
207
+ })
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // findFirstChildIndex
211
+ // ---------------------------------------------------------------------------
212
+
213
+ describe("findFirstChildIndex", () => {
214
+ it("returns next index when the next span is deeper", () => {
215
+ const spans = buildTree()
216
+ expect(findFirstChildIndex(spans, indexOfId(spans, "a"))).toBe(indexOfId(spans, "a1"))
217
+ expect(findFirstChildIndex(spans, indexOfId(spans, "b1"))).toBe(indexOfId(spans, "b1a"))
218
+ expect(findFirstChildIndex(spans, indexOfId(spans, "root"))).toBe(indexOfId(spans, "a"))
219
+ })
220
+
221
+ it("returns null for leaf spans", () => {
222
+ const spans = buildTree()
223
+ expect(findFirstChildIndex(spans, indexOfId(spans, "a1"))).toBeNull()
224
+ expect(findFirstChildIndex(spans, indexOfId(spans, "c"))).toBeNull()
225
+ })
226
+
227
+ it("returns null on out-of-range index", () => {
228
+ const spans = buildTree()
229
+ expect(findFirstChildIndex(spans, 999)).toBeNull()
230
+ expect(findFirstChildIndex(spans, -1)).toBeNull()
231
+ })
232
+
233
+ it("returns null on empty input", () => {
234
+ expect(findFirstChildIndex([], 0)).toBeNull()
235
+ })
236
+ })
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // resolveCollapseStep — the heart of the keyboard logic
240
+ // ---------------------------------------------------------------------------
241
+
242
+ describe("resolveCollapseStep — `right` (l / expand-or-into)", () => {
243
+ it("expanding a collapsed node keeps the same span selected", () => {
244
+ const spans = buildTree()
245
+ const collapsed = new Set(["a"])
246
+ const r = step(spans, collapsed, "a", "right")
247
+ expect(r.collapsed.has("a")).toBe(false)
248
+ expect(r.selectedSpanId).toBe("a")
249
+ expect(r.visibleIds).toEqual(["root", "a", "a1", "a2", "b", "b1", "b1a", "c"])
250
+ })
251
+
252
+ it("on an expanded parent, walks selection into its first visible child", () => {
253
+ const spans = buildTree()
254
+ const r = step(spans, new Set(), "a", "right")
255
+ expect(r.collapsed.size).toBe(0)
256
+ expect(r.selectedSpanId).toBe("a1")
257
+ })
258
+
259
+ it("on a leaf, is a no-op", () => {
260
+ const spans = buildTree()
261
+ const r = step(spans, new Set(), "a1", "right")
262
+ expect(r.collapsed.size).toBe(0)
263
+ expect(r.selectedSpanId).toBe("a1")
264
+ })
265
+
266
+ it("on root (expanded with children) walks into first child", () => {
267
+ const spans = buildTree()
268
+ const r = step(spans, new Set(), "root", "right")
269
+ expect(r.selectedSpanId).toBe("a")
270
+ })
271
+
272
+ it("on root (collapsed) expands it", () => {
273
+ const spans = buildTree()
274
+ const r = step(spans, new Set(["root"]), "root", "right")
275
+ expect(r.collapsed.has("root")).toBe(false)
276
+ expect(r.selectedSpanId).toBe("root")
277
+ })
278
+
279
+ it("when nothing is selected, is a no-op", () => {
280
+ const spans = buildTree()
281
+ const r = step(spans, new Set(), null, "right")
282
+ expect(r.selectedSpanId).toBeNull()
283
+ expect(r.collapsed.size).toBe(0)
284
+ })
285
+
286
+ it("when index is stale (past visible end), is a no-op rather than crashing", () => {
287
+ const spans = buildTree()
288
+ const out = resolveCollapseStep({ spans, collapsed: new Set(), selectedIndex: 999, direction: "right" })
289
+ expect(out.selectedIndex).toBe(999)
290
+ expect(out.collapsed.size).toBe(0)
291
+ })
292
+ })
293
+
294
+ describe("resolveCollapseStep — `left` (h / collapse-or-up)", () => {
295
+ it("collapsing an expanded parent keeps the same span selected", () => {
296
+ const spans = buildTree()
297
+ const r = step(spans, new Set(), "a", "left")
298
+ expect(r.collapsed.has("a")).toBe(true)
299
+ expect(r.selectedSpanId).toBe("a")
300
+ expect(r.visibleIds).toEqual(["root", "a", "b", "b1", "b1a", "c"])
301
+ })
302
+
303
+ it("on a leaf, walks to its parent", () => {
304
+ const spans = buildTree()
305
+ const r = step(spans, new Set(), "a1", "left")
306
+ expect(r.selectedSpanId).toBe("a")
307
+ expect(r.collapsed.size).toBe(0)
308
+ })
309
+
310
+ it("on a deep leaf, walks to its immediate parent (not all the way to root)", () => {
311
+ const spans = buildTree()
312
+ const r = step(spans, new Set(), "b1a", "left")
313
+ expect(r.selectedSpanId).toBe("b1")
314
+ })
315
+
316
+ it("on a collapsed parent, walks to its parent (since it has no expanded kids to collapse)", () => {
317
+ const spans = buildTree()
318
+ const r = step(spans, new Set(["a"]), "a", "left")
319
+ expect(r.selectedSpanId).toBe("root")
320
+ expect(r.collapsed.has("a")).toBe(true) // staying collapsed
321
+ })
322
+
323
+ it("on root, is a no-op", () => {
324
+ const spans = buildTree()
325
+ const r = step(spans, new Set(["root"]), "root", "left")
326
+ expect(r.selectedSpanId).toBe("root")
327
+ })
328
+
329
+ it("when nothing is selected, is a no-op", () => {
330
+ const spans = buildTree()
331
+ const r = step(spans, new Set(), null, "left")
332
+ expect(r.selectedSpanId).toBeNull()
333
+ expect(r.collapsed.size).toBe(0)
334
+ })
335
+ })
336
+
337
+ describe("resolveCollapseStep — sequences (real bug scenarios)", () => {
338
+ it("press l then l: navigates root → a → a1 (no double-collapse)", () => {
339
+ const spans = buildTree()
340
+ const r1 = step(spans, new Set(), "root", "right")
341
+ expect(r1.selectedSpanId).toBe("a")
342
+ const r2 = step(spans, r1.collapsed, r1.selectedSpanId!, "right")
343
+ expect(r2.selectedSpanId).toBe("a1")
344
+ })
345
+
346
+ it("h-then-h-then-h on a leaf reaches root via collapse-then-walk pattern", () => {
347
+ const spans = buildTree()
348
+ // b1a -> h -> b1 (walk parent)
349
+ const r1 = step(spans, new Set(), "b1a", "left")
350
+ expect(r1.selectedSpanId).toBe("b1")
351
+ // b1 -> h -> collapse b1
352
+ const r2 = step(spans, r1.collapsed, r1.selectedSpanId!, "left")
353
+ expect(r2.collapsed.has("b1")).toBe(true)
354
+ expect(r2.selectedSpanId).toBe("b1")
355
+ // b1 (collapsed) -> h -> walk to parent b
356
+ const r3 = step(spans, r2.collapsed, r2.selectedSpanId!, "left")
357
+ expect(r3.selectedSpanId).toBe("b")
358
+ })
359
+
360
+ it("rapid collapse/expand on the same node converges to the original state", () => {
361
+ const spans = buildTree()
362
+ let st: { collapsed: ReadonlySet<string>; selectedSpanId: string } = {
363
+ collapsed: new Set(),
364
+ selectedSpanId: "a",
365
+ }
366
+ for (let i = 0; i < 10; i++) {
367
+ const dir = i % 2 === 0 ? "left" : "right"
368
+ const r = step(spans, st.collapsed, st.selectedSpanId, dir)
369
+ st = { collapsed: r.collapsed, selectedSpanId: r.selectedSpanId! }
370
+ }
371
+ expect([...st.collapsed]).toEqual([])
372
+ expect(st.selectedSpanId).toBe("a")
373
+ })
374
+
375
+ it("walking right past a leaf is idempotent (no crash, no state change)", () => {
376
+ const spans = buildTree()
377
+ const r1 = step(spans, new Set(), "a1", "right")
378
+ const r2 = step(spans, r1.collapsed, r1.selectedSpanId!, "right")
379
+ const r3 = step(spans, r2.collapsed, r2.selectedSpanId!, "right")
380
+ expect(r1.selectedSpanId).toBe("a1")
381
+ expect(r2.selectedSpanId).toBe("a1")
382
+ expect(r3.selectedSpanId).toBe("a1")
383
+ })
384
+
385
+ it("walking left past root is idempotent", () => {
386
+ const spans = buildTree()
387
+ const r1 = step(spans, new Set(["root"]), "root", "left")
388
+ const r2 = step(spans, r1.collapsed, r1.selectedSpanId!, "left")
389
+ expect(r1.selectedSpanId).toBe("root")
390
+ expect(r2.selectedSpanId).toBe("root")
391
+ })
392
+
393
+ it("collapse then move to another span then expand: state stays consistent", () => {
394
+ const spans = buildTree()
395
+ // Collapse `a` while on it.
396
+ const r1 = step(spans, new Set(), "a", "left")
397
+ expect(r1.collapsed.has("a")).toBe(true)
398
+ // Visible: root, a, b, b1, b1a, c
399
+ // Pretend user clicks `b1a` (so selection moves there).
400
+ // Now press h on b1a.
401
+ const r2 = step(spans, r1.collapsed, "b1a", "left")
402
+ expect(r2.selectedSpanId).toBe("b1")
403
+ expect(r2.collapsed.has("a")).toBe(true) // a stays collapsed
404
+ })
405
+ })
406
+
407
+ describe("resolveCollapseStep — invariants", () => {
408
+ it("never returns a selectedIndex that is out of the new visible range", () => {
409
+ const spans = buildTree()
410
+ // Try every (selectedSpanId, direction, collapsed-state) combination we care about.
411
+ const interesting: ReadonlyArray<{ collapsed: ReadonlySet<string>; selectedSpanId: string }> = [
412
+ { collapsed: new Set(), selectedSpanId: "root" },
413
+ { collapsed: new Set(), selectedSpanId: "a" },
414
+ { collapsed: new Set(), selectedSpanId: "a2" },
415
+ { collapsed: new Set(), selectedSpanId: "b1a" },
416
+ { collapsed: new Set(["a"]), selectedSpanId: "a" },
417
+ { collapsed: new Set(["a"]), selectedSpanId: "b" },
418
+ { collapsed: new Set(["b"]), selectedSpanId: "b" },
419
+ { collapsed: new Set(["root"]), selectedSpanId: "root" },
420
+ ]
421
+ for (const { collapsed, selectedSpanId } of interesting) {
422
+ for (const direction of ["left", "right"] as const) {
423
+ const r = step(spans, collapsed, selectedSpanId, direction)
424
+ if (r.selectedIndex !== null) {
425
+ expect(r.selectedIndex).toBeGreaterThanOrEqual(0)
426
+ expect(r.selectedIndex).toBeLessThan(r.visibleIds.length)
427
+ }
428
+ }
429
+ }
430
+ })
431
+
432
+ it("collapsing then expanding the same node restores the original state", () => {
433
+ const spans = buildTree()
434
+ const a = step(spans, new Set(), "a", "left") // collapse a
435
+ const b = step(spans, a.collapsed, a.selectedSpanId!, "right") // expand a
436
+ expect([...b.collapsed]).toEqual([])
437
+ expect(b.selectedSpanId).toBe("a")
438
+ expect(b.visibleIds).toEqual(idsOf(spans))
439
+ })
440
+
441
+ it("collapsing an ancestor of the currently selected span keeps the ancestor visible (selection moves up)", () => {
442
+ // Start: selected = b1a (depth 3). Collapse `b` (its grandparent).
443
+ const spans = buildTree()
444
+ // We collapse `b` while `b1a` is selected. The keyboard handler only collapses
445
+ // the currently selected span, so we simulate the click-to-collapse path by
446
+ // asserting the helper handles a selection that became invisible afterward.
447
+ const collapsed = new Set(["b"])
448
+ const visible = getVisibleSpans(spans, collapsed)
449
+ // `b1a` is no longer visible. Its nearest visible ancestor is `b`.
450
+ expect(visible.some((s) => s.spanId === "b1a")).toBe(false)
451
+ expect(visible.some((s) => s.spanId === "b")).toBe(true)
452
+ })
453
+ })
@@ -0,0 +1,75 @@
1
+ import type { TraceSpanItem } from "../domain.ts"
2
+ import { findFirstChildIndex, findParentIndex, getVisibleSpans } from "./waterfallModel.ts"
3
+
4
+ export type CollapseStep = {
5
+ readonly collapsed: ReadonlySet<string>
6
+ readonly selectedIndex: number | null
7
+ }
8
+
9
+ export type ResolveCollapseParams = {
10
+ readonly spans: readonly TraceSpanItem[]
11
+ readonly collapsed: ReadonlySet<string>
12
+ readonly selectedIndex: number | null
13
+ readonly direction: "left" | "right"
14
+ }
15
+
16
+ /**
17
+ * Pure resolver for the `h` (left) / `l` (right) keys in the waterfall.
18
+ *
19
+ * Semantics:
20
+ * - `right`: if the selected span has children and is currently collapsed,
21
+ * expand it; selection stays on the same span. Otherwise, if it has visible
22
+ * children, move selection to its first child. Otherwise no-op.
23
+ * - `left`: if the selected span has children and is currently expanded,
24
+ * collapse it; selection stays on the same span. Otherwise, if it has a
25
+ * parent in the visible list, move selection to that parent. Otherwise no-op.
26
+ *
27
+ * Stale or out-of-range selection indices are treated as no-ops rather than
28
+ * crashing — defensive against the one-frame window between a state change
29
+ * and the next render.
30
+ */
31
+ export const resolveCollapseStep = ({
32
+ spans,
33
+ collapsed,
34
+ selectedIndex,
35
+ direction,
36
+ }: ResolveCollapseParams): CollapseStep => {
37
+ const noChange: CollapseStep = { collapsed, selectedIndex }
38
+
39
+ if (selectedIndex === null) return noChange
40
+
41
+ const visible = getVisibleSpans(spans, collapsed)
42
+ if (selectedIndex < 0 || selectedIndex >= visible.length) return noChange
43
+
44
+ const span = visible[selectedIndex]!
45
+ const fullIndex = spans.indexOf(span)
46
+ const hasChildren = fullIndex >= 0 && findFirstChildIndex(spans, fullIndex) !== null
47
+ const isCollapsed = collapsed.has(span.spanId)
48
+
49
+ if (direction === "right") {
50
+ // Expand a collapsed parent (selection stays).
51
+ if (hasChildren && isCollapsed) {
52
+ const next = new Set(collapsed)
53
+ next.delete(span.spanId)
54
+ return { collapsed: next, selectedIndex }
55
+ }
56
+ // Walk into first visible child.
57
+ if (hasChildren) {
58
+ const childIdx = findFirstChildIndex(visible, selectedIndex)
59
+ if (childIdx !== null) return { collapsed, selectedIndex: childIdx }
60
+ }
61
+ return noChange
62
+ }
63
+
64
+ // direction === "left"
65
+ // Collapse an expanded parent (selection stays).
66
+ if (hasChildren && !isCollapsed) {
67
+ const next = new Set(collapsed)
68
+ next.add(span.spanId)
69
+ return { collapsed: next, selectedIndex }
70
+ }
71
+ // Walk to parent in the visible list.
72
+ const parentIdx = findParentIndex(visible, selectedIndex)
73
+ if (parentIdx !== null) return { collapsed, selectedIndex: parentIdx }
74
+ return noChange
75
+ }
@@ -0,0 +1,2 @@
1
+ /*! tailwindcss v4.3.1 | MIT License | https://tailwindcss.com */
2
+ @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:"Berkeley Mono", "IBM Plex Mono", "JetBrains Mono", "Fira Code", ui-monospace, monospace;--color-red-400:oklch(70.4% .191 22.216);--color-amber-400:oklch(82.8% .189 84.429);--color-emerald-400:oklch(76.5% .177 163.223);--color-sky-400:oklch(74.6% .16 232.661);--color-zinc-50:oklch(98.5% 0 0);--color-zinc-100:oklch(96.7% .001 286.375);--color-zinc-200:oklch(92% .004 286.32);--color-zinc-300:oklch(87.1% .006 286.286);--color-zinc-400:oklch(70.5% .015 286.067);--color-zinc-500:oklch(55.2% .016 285.938);--color-zinc-600:oklch(44.2% .017 285.786);--color-zinc-700:oklch(37% .013 285.805);--color-zinc-900:oklch(21% .006 285.885);--color-zinc-950:oklch(14.1% .005 285.823);--color-white:#fff;--spacing:.25rem;--container-2xl:42rem;--container-3xl:48rem;--container-7xl:80rem;--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--font-weight-medium:500;--font-weight-semibold:600;--tracking-tight:-.025em;--radius-sm:.25rem;--radius-md:.375rem;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-accent:oklch(79.5% .16 70);--color-accent-dim:oklch(65% .12 70)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.relative{position:relative}.static{position:static}.sticky{position:sticky}.top-0{top:0}.top-1\/2{top:50%}.right-0{right:0}.right-1{right:var(--spacing)}.bottom-0{bottom:0}.bottom-1{bottom:var(--spacing)}.left-0{left:0}.left-1{left:var(--spacing)}.isolate{isolation:isolate}.z-10{z-index:10}.z-20{z-index:20}.container{width:100%}@media (width>=40rem){.container{max-width:40rem}}@media (width>=48rem){.container{max-width:48rem}}@media (width>=64rem){.container{max-width:64rem}}@media (width>=80rem){.container{max-width:80rem}}@media (width>=96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mb-1{margin-bottom:var(--spacing)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.ml-auto{margin-left:auto}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-block{display:inline-block}.size-1\.5{width:calc(var(--spacing) * 1.5);height:calc(var(--spacing) * 1.5)}.h-3{height:calc(var(--spacing) * 3)}.h-full{height:100%}.min-h-0{min-height:0}.w-16{width:calc(var(--spacing) * 16)}.w-28{width:calc(var(--spacing) * 28)}.w-72{width:calc(var(--spacing) * 72)}.w-\[7rem\]{width:7rem}.w-\[440px\]{width:440px}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-28{max-width:calc(var(--spacing) * 28)}.max-w-32{max-width:calc(var(--spacing) * 32)}.min-w-0{min-width:0}.min-w-80{min-width:calc(var(--spacing) * 80)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.cursor-pointer{cursor:pointer}.grid-cols-\[auto_1fr\]{grid-template-columns:auto 1fr}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-x-5{column-gap:calc(var(--spacing) * 5)}.gap-y-1{row-gap:var(--spacing)}.gap-y-1\.5{row-gap:calc(var(--spacing) * 1.5)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-none{--tw-border-style:none;border-style:none}.border-accent-dim\/50{border-color:#bd813080}@supports (color:color-mix(in lab, red, red)){.border-accent-dim\/50{border-color:color-mix(in oklab, var(--color-accent-dim) 50%, transparent)}}.border-white\/5{border-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.border-white\/5{border-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.border-white\/10{border-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.border-white\/10{border-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.border-white\/\[0\.03\]{border-color:#ffffff08}@supports (color:color-mix(in lab, red, red)){.border-white\/\[0\.03\]{border-color:color-mix(in oklab, var(--color-white) 3%, transparent)}}.bg-accent\/5{background-color:#fca72c0d}@supports (color:color-mix(in lab, red, red)){.bg-accent\/5{background-color:color-mix(in oklab, var(--color-accent) 5%, transparent)}}.bg-accent\/10{background-color:#fca72c1a}@supports (color:color-mix(in lab, red, red)){.bg-accent\/10{background-color:color-mix(in oklab, var(--color-accent) 10%, transparent)}}.bg-accent\/15{background-color:#fca72c26}@supports (color:color-mix(in lab, red, red)){.bg-accent\/15{background-color:color-mix(in oklab, var(--color-accent) 15%, transparent)}}.bg-amber-400\/10{background-color:#fcbb001a}@supports (color:color-mix(in lab, red, red)){.bg-amber-400\/10{background-color:color-mix(in oklab, var(--color-amber-400) 10%, transparent)}}.bg-emerald-400\/10{background-color:#00d2941a}@supports (color:color-mix(in lab, red, red)){.bg-emerald-400\/10{background-color:color-mix(in oklab, var(--color-emerald-400) 10%, transparent)}}.bg-red-400\/10{background-color:#ff65681a}@supports (color:color-mix(in lab, red, red)){.bg-red-400\/10{background-color:color-mix(in oklab, var(--color-red-400) 10%, transparent)}}.bg-sky-400\/10{background-color:#00bcfe1a}@supports (color:color-mix(in lab, red, red)){.bg-sky-400\/10{background-color:color-mix(in oklab, var(--color-sky-400) 10%, transparent)}}.bg-transparent{background-color:#0000}.bg-white\/10{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.bg-white\/10{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.bg-zinc-500\/10{background-color:#71717b1a}@supports (color:color-mix(in lab, red, red)){.bg-zinc-500\/10{background-color:color-mix(in oklab, var(--color-zinc-500) 10%, transparent)}}.bg-zinc-600\/10{background-color:#52525c1a}@supports (color:color-mix(in lab, red, red)){.bg-zinc-600\/10{background-color:color-mix(in oklab, var(--color-zinc-600) 10%, transparent)}}.bg-zinc-900{background-color:var(--color-zinc-900)}.bg-zinc-900\/50{background-color:#18181b80}@supports (color:color-mix(in lab, red, red)){.bg-zinc-900\/50{background-color:color-mix(in oklab, var(--color-zinc-900) 50%, transparent)}}.bg-zinc-950{background-color:var(--color-zinc-950)}.p-16{padding:calc(var(--spacing) * 16)}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-20{padding-block:calc(var(--spacing) * 20)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pr-1\.5{padding-right:calc(var(--spacing) * 1.5)}.pr-2{padding-right:calc(var(--spacing) * 2)}.pr-4{padding-right:calc(var(--spacing) * 4)}.pr-6{padding-right:calc(var(--spacing) * 6)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pl-1\.5{padding-left:calc(var(--spacing) * 1.5)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-6{padding-left:calc(var(--spacing) * 6)}.text-left{text-align:left}.text-right{text-align:right}.align-top{vertical-align:top}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.leading-none{--tw-leading:1;line-height:1}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.text-balance{text-wrap:balance}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.text-ellipsis{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-accent{color:var(--color-accent)}.text-amber-400{color:var(--color-amber-400)}.text-emerald-400{color:var(--color-emerald-400)}.text-inherit{color:inherit}.text-red-400{color:var(--color-red-400)}.text-sky-400{color:var(--color-sky-400)}.text-white\/90{color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.text-white\/90{color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.text-zinc-50{color:var(--color-zinc-50)}.text-zinc-100{color:var(--color-zinc-100)}.text-zinc-200{color:var(--color-zinc-200)}.text-zinc-300{color:var(--color-zinc-300)}.text-zinc-400{color:var(--color-zinc-400)}.text-zinc-500{color:var(--color-zinc-500)}.text-zinc-600{color:var(--color-zinc-600)}.text-zinc-700{color:var(--color-zinc-700)}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.no-underline{text-decoration-line:none}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.scheme-only-dark{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark only}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.placeholder\:text-zinc-600::placeholder{color:var(--color-zinc-600)}.last\:border-0:last-child{border-style:var(--tw-border-style);border-width:0}@media (hover:hover){.hover\:border-white\/20:hover{border-color:#fff3}@supports (color:color-mix(in lab, red, red)){.hover\:border-white\/20:hover{border-color:color-mix(in oklab, var(--color-white) 20%, transparent)}}.hover\:bg-white\/5:hover{background-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/5:hover{background-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.hover\:bg-white\/\[0\.03\]:hover{background-color:#ffffff08}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/\[0\.03\]:hover{background-color:color-mix(in oklab, var(--color-white) 3%, transparent)}}.hover\:text-accent:hover{color:var(--color-accent)}.hover\:text-zinc-200:hover{color:var(--color-zinc-200)}.hover\:text-zinc-300:hover{color:var(--color-zinc-300)}.hover\:underline:hover{text-decoration-line:underline}}.focus\:border-accent-dim:focus{border-color:var(--color-accent-dim)}}html,body,#root{height:100%;font-family:var(--font-mono)}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:0 0}::-webkit-scrollbar-thumb{background:var(--color-white)/.1;border-radius:3px}::-webkit-scrollbar-thumb:hover{background:var(--color-white)/.2}@keyframes pulse-bar{0%,to{opacity:.5}50%{opacity:1}}.animate-bar-pulse{animation:1.5s ease-in-out infinite pulse-bar}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}