@hybridlabor-api/bdb-synapse 1.0.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 (102) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +173 -0
  3. package/bin/synapse +0 -0
  4. package/cmd/rubriceval/main.go +308 -0
  5. package/cmd/synapse/main.go +280 -0
  6. package/cmd/synapse/main_test.go +16 -0
  7. package/go.mod +7 -0
  8. package/go.sum +6 -0
  9. package/internal/adapter/adapter.go +1117 -0
  10. package/internal/adapter/adapter_test.go +518 -0
  11. package/internal/adapter/agy/adapter.go +193 -0
  12. package/internal/adapter/claudecode/adapter.go +415 -0
  13. package/internal/adapter/claudecode/adapter_test.go +260 -0
  14. package/internal/adapter/claudecode/agents.go +387 -0
  15. package/internal/adapter/claudecode/agents_test.go +480 -0
  16. package/internal/adapter/claudecode/summary_inputs_test.go +19 -0
  17. package/internal/adapter/codex/adapter.go +919 -0
  18. package/internal/adapter/codex/adapter_test.go +920 -0
  19. package/internal/adapter/codex/agents.go +401 -0
  20. package/internal/adapter/codex/agents_test.go +610 -0
  21. package/internal/adapter/codex/summary_inputs_test.go +20 -0
  22. package/internal/adapter/pi/adapter.go +483 -0
  23. package/internal/adapter/pi/adapter_test.go +517 -0
  24. package/internal/citymap/builder.go +1124 -0
  25. package/internal/citymap/builder_test.go +818 -0
  26. package/internal/judge/cache.go +180 -0
  27. package/internal/judge/cli.go +240 -0
  28. package/internal/judge/cli_test.go +68 -0
  29. package/internal/judge/fresh_summary_test.go +37 -0
  30. package/internal/judge/input.go +233 -0
  31. package/internal/judge/judge.go +416 -0
  32. package/internal/judge/judge_test.go +288 -0
  33. package/internal/judge/prompt.go +129 -0
  34. package/internal/judge/rubric.go +275 -0
  35. package/internal/judge/rubric_test.go +642 -0
  36. package/internal/model/agent.go +63 -0
  37. package/internal/model/agent_schema_test.go +86 -0
  38. package/internal/model/agent_test.go +64 -0
  39. package/internal/model/model.go +175 -0
  40. package/internal/model/report.go +166 -0
  41. package/internal/model/stats.go +151 -0
  42. package/internal/model/stats_test.go +89 -0
  43. package/internal/model/trace_schema_test.go +67 -0
  44. package/internal/server/analyze.go +286 -0
  45. package/internal/server/analyze_test.go +297 -0
  46. package/internal/server/codex_index_test.go +40 -0
  47. package/internal/server/hardening_test.go +147 -0
  48. package/internal/server/reportindex.go +91 -0
  49. package/internal/server/reportindex_test.go +116 -0
  50. package/internal/server/server.go +1099 -0
  51. package/internal/server/server_test.go +1389 -0
  52. package/internal/server/static/assets/fraunces-latin-ext-standard-italic-CGbN9UgK.woff2 +0 -0
  53. package/internal/server/static/assets/fraunces-latin-ext-standard-normal-CJcjJNj7.woff2 +0 -0
  54. package/internal/server/static/assets/fraunces-latin-standard-italic-lSdLDfvT.woff2 +0 -0
  55. package/internal/server/static/assets/fraunces-latin-standard-normal-DihXLNYH.woff2 +0 -0
  56. package/internal/server/static/assets/fraunces-vietnamese-standard-italic-DxWqP7Ku.woff2 +0 -0
  57. package/internal/server/static/assets/fraunces-vietnamese-standard-normal-Czevyj-6.woff2 +0 -0
  58. package/internal/server/static/assets/index-BNoY_BiB.css +1 -0
  59. package/internal/server/static/assets/index-C_adLrJr.js +3 -0
  60. package/internal/server/static/assets/react-gcHzaSmV.js +10 -0
  61. package/internal/server/static/assets/schibsted-grotesk-latin-ext-wght-normal-hsMS0n0O.woff2 +0 -0
  62. package/internal/server/static/assets/schibsted-grotesk-latin-wght-normal-Bb8VGrTG.woff2 +0 -0
  63. package/internal/server/static/assets/three-DnGjZfD1.js +4012 -0
  64. package/internal/server/static/index.html +26 -0
  65. package/internal/server/tracestore.go +173 -0
  66. package/internal/server/tracestore_test.go +51 -0
  67. package/internal/textutil/truncate.go +30 -0
  68. package/internal/textutil/truncate_test.go +39 -0
  69. package/package.json +35 -0
  70. package/web/e2e/agent-lens.spec.ts +688 -0
  71. package/web/index.html +23 -0
  72. package/web/package-lock.json +1933 -0
  73. package/web/package.json +33 -0
  74. package/web/playwright.config.ts +24 -0
  75. package/web/src/App.tsx +876 -0
  76. package/web/src/api/client.ts +74 -0
  77. package/web/src/main.tsx +12 -0
  78. package/web/src/playback/recorder.ts +160 -0
  79. package/web/src/playback/reducer.ts +91 -0
  80. package/web/src/scene/CityScene.tsx +638 -0
  81. package/web/src/scene/TreeScene.tsx +656 -0
  82. package/web/src/scene/dirLabels.ts +145 -0
  83. package/web/src/scene/sceneUtils.ts +144 -0
  84. package/web/src/scene/textures.ts +60 -0
  85. package/web/src/scene/trail.ts +79 -0
  86. package/web/src/scene/treeLayout.ts +169 -0
  87. package/web/src/state/filters.ts +40 -0
  88. package/web/src/state/store.ts +83 -0
  89. package/web/src/styles.css +2565 -0
  90. package/web/src/types.ts +315 -0
  91. package/web/src/ui/AgentsPanel.tsx +376 -0
  92. package/web/src/ui/Dock.tsx +104 -0
  93. package/web/src/ui/Hud.tsx +335 -0
  94. package/web/src/ui/Inspector.tsx +107 -0
  95. package/web/src/ui/LogoMark.tsx +38 -0
  96. package/web/src/ui/ReportPanel.tsx +491 -0
  97. package/web/src/ui/SessionRail.tsx +316 -0
  98. package/web/src/ui/Timeline.tsx +458 -0
  99. package/web/src/ui/ViewPanel.tsx +45 -0
  100. package/web/src/ui/shortcuts.ts +4 -0
  101. package/web/tsconfig.json +21 -0
  102. package/web/vite.config.ts +28 -0
@@ -0,0 +1,458 @@
1
+ import { Ellipsis, Loader, Pause, Play, RotateCcw, StepBack, StepForward, Video } from "lucide-react";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import type { Action, Mark, Trace, TraceEvent } from "../types";
4
+
5
+ interface TimelineProps {
6
+ trace?: Trace;
7
+ currentSeq: number;
8
+ onChange: (seq: number) => void;
9
+ onSubagentMark?: (seq: number) => void;
10
+ onExport?: () => void;
11
+ exporting?: boolean;
12
+ }
13
+
14
+ const BUCKETS = 160;
15
+ const BASE_TICK_MS = 340;
16
+ const SPEEDS = [1, 4, 16] as const;
17
+ type Speed = (typeof SPEEDS)[number];
18
+
19
+ interface Bucket {
20
+ count: number;
21
+ dominant: Action;
22
+ }
23
+
24
+ // marks that land on the same strip pixel collapse into one glyph; the title
25
+ // carries the count so dense sessions stay legible
26
+ interface MarkGroup {
27
+ type: Mark["type"];
28
+ seq: number;
29
+ pos: number;
30
+ count: number;
31
+ note?: string;
32
+ }
33
+
34
+ const MARK_SLOTS = 220;
35
+
36
+ const MARK_LABEL: Record<Mark["type"], string> = {
37
+ compaction: "context compaction",
38
+ "user-message": "user message",
39
+ subagent: "subagent"
40
+ };
41
+
42
+ const STRIP_ACTIONS: Action[] = ["search", "read", "edit", "verify", "exec"];
43
+
44
+ export function Timeline({
45
+ trace,
46
+ currentSeq,
47
+ onChange,
48
+ onSubagentMark,
49
+ onExport,
50
+ exporting = false
51
+ }: TimelineProps) {
52
+ const [playing, setPlaying] = useState(false);
53
+ const [speed, setSpeed] = useState<Speed>(1);
54
+ const [menuOpen, setMenuOpen] = useState(false);
55
+ const menuRef = useRef<HTMLDivElement>(null);
56
+ const total = trace?.events.length ?? 0;
57
+ const max = Math.max(0, total - 1);
58
+ const seq = Math.min(currentSeq, max);
59
+ const event = trace?.events[seq];
60
+
61
+ useEffect(() => {
62
+ setPlaying(false);
63
+ }, [trace]);
64
+
65
+ // while a video export is recording, the recorder owns the playhead — force
66
+ // playback off so the Timeline timer can't fight it for currentSeq
67
+ useEffect(() => {
68
+ if (exporting) setPlaying(false);
69
+ }, [exporting]);
70
+
71
+ // the timer and shortcuts read position via refs so ticking doesn't tear them down
72
+ const seqRef = useRef(seq);
73
+ const maxRef = useRef(max);
74
+ const playingRef = useRef(playing);
75
+ seqRef.current = seq;
76
+ maxRef.current = max;
77
+ playingRef.current = playing;
78
+
79
+ useEffect(() => {
80
+ if (!playing || total === 0 || exporting) return;
81
+ // higher speeds keep the render rate bounded by advancing several events per tick
82
+ const interval = Math.max(85, BASE_TICK_MS / speed);
83
+ const step = Math.max(1, Math.round((speed * interval) / BASE_TICK_MS));
84
+ const timer = window.setInterval(() => {
85
+ if (seqRef.current >= maxRef.current) {
86
+ setPlaying(false);
87
+ return;
88
+ }
89
+ onChange(Math.min(seqRef.current + step, maxRef.current));
90
+ }, interval);
91
+ return () => window.clearInterval(timer);
92
+ }, [playing, speed, total, onChange, exporting]);
93
+
94
+ const togglePlay = useCallback(() => {
95
+ if (!playingRef.current && seqRef.current >= maxRef.current) onChange(0);
96
+ setPlaying((v) => !v);
97
+ }, [onChange]);
98
+
99
+ const cycleSpeed = useCallback(() => {
100
+ setSpeed((s) => SPEEDS[(SPEEDS.indexOf(s) + 1) % SPEEDS.length]);
101
+ }, []);
102
+
103
+ const step = useCallback(
104
+ (delta: number) => {
105
+ onChange(Math.min(maxRef.current, Math.max(0, seqRef.current + delta)));
106
+ },
107
+ [onChange]
108
+ );
109
+
110
+ const jumpEvent = useCallback(
111
+ (dir: 1 | -1, pred: (e: TraceEvent) => boolean) => {
112
+ if (!trace) return;
113
+ for (let i = seqRef.current + dir; i >= 0 && i < trace.events.length; i += dir) {
114
+ if (pred(trace.events[i])) {
115
+ onChange(i);
116
+ return;
117
+ }
118
+ }
119
+ },
120
+ [trace, onChange]
121
+ );
122
+
123
+ const markSeqs = useMemo(() => {
124
+ const clamped = (trace?.marks ?? []).map((mark) => Math.min(mark.seq, Math.max(0, (trace?.events.length ?? 1) - 1)));
125
+ return [...new Set(clamped)].sort((a, b) => a - b);
126
+ }, [trace]);
127
+
128
+ const jumpMark = useCallback(
129
+ (dir: 1 | -1) => {
130
+ const cur = seqRef.current;
131
+ const next = dir === 1 ? markSeqs.find((s) => s > cur) : [...markSeqs].reverse().find((s) => s < cur);
132
+ if (next !== undefined) onChange(next);
133
+ },
134
+ [markSeqs, onChange]
135
+ );
136
+
137
+ // playback shortcuts; scene and rail keep their own (⌘B lives in App).
138
+ // suspended while exporting so a keypress can't move the recorded playhead.
139
+ useEffect(() => {
140
+ if (!trace || exporting) return;
141
+ const onKeyDown = (e: KeyboardEvent) => {
142
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
143
+ const target = e.target as HTMLElement | null;
144
+ if (target?.closest("input, textarea, select, button, [contenteditable]")) return;
145
+ switch (e.key) {
146
+ case " ":
147
+ e.preventDefault();
148
+ togglePlay();
149
+ return;
150
+ case "ArrowLeft":
151
+ e.preventDefault();
152
+ step(e.shiftKey ? -10 : -1);
153
+ return;
154
+ case "ArrowRight":
155
+ e.preventDefault();
156
+ step(e.shiftKey ? 10 : 1);
157
+ return;
158
+ case "Home":
159
+ e.preventDefault();
160
+ onChange(0);
161
+ return;
162
+ case "End":
163
+ e.preventDefault();
164
+ onChange(maxRef.current);
165
+ return;
166
+ }
167
+ switch (e.key.toLowerCase()) {
168
+ case "s":
169
+ cycleSpeed();
170
+ return;
171
+ case "e":
172
+ jumpEvent(e.shiftKey ? -1 : 1, (ev) => ev.action === "edit");
173
+ return;
174
+ case "x":
175
+ jumpEvent(e.shiftKey ? -1 : 1, (ev) => ev.isError);
176
+ return;
177
+ case "m":
178
+ jumpMark(e.shiftKey ? -1 : 1);
179
+ return;
180
+ }
181
+ };
182
+ window.addEventListener("keydown", onKeyDown);
183
+ return () => window.removeEventListener("keydown", onKeyDown);
184
+ }, [trace, exporting, togglePlay, step, onChange, cycleSpeed, jumpEvent, jumpMark]);
185
+
186
+ // transport + scrubber are inert with no trace, or while an export owns the playhead
187
+ const locked = total === 0 || exporting;
188
+
189
+ useEffect(() => {
190
+ if (locked) setMenuOpen(false);
191
+ }, [locked]);
192
+
193
+ useEffect(() => {
194
+ if (!menuOpen) return;
195
+ const onDown = (e: PointerEvent) => {
196
+ if (!menuRef.current?.contains(e.target as Node)) setMenuOpen(false);
197
+ };
198
+ const onKey = (e: KeyboardEvent) => {
199
+ if (e.key === "Escape") setMenuOpen(false);
200
+ };
201
+ window.addEventListener("pointerdown", onDown);
202
+ window.addEventListener("keydown", onKey);
203
+ return () => {
204
+ window.removeEventListener("pointerdown", onDown);
205
+ window.removeEventListener("keydown", onKey);
206
+ };
207
+ }, [menuOpen]);
208
+
209
+ const buckets = useMemo<Bucket[]>(() => {
210
+ if (!trace || total === 0) return [];
211
+ const n = Math.min(BUCKETS, total);
212
+ const out: Bucket[] = [];
213
+ for (let b = 0; b < n; b++) {
214
+ const from = Math.floor((b * total) / n);
215
+ const to = Math.floor(((b + 1) * total) / n);
216
+ const byAction = new Map<Action, number>();
217
+ for (let i = from; i < to; i++) {
218
+ const action = trace.events[i].action;
219
+ byAction.set(action, (byAction.get(action) ?? 0) + 1);
220
+ }
221
+ let dominant: Action = "other";
222
+ let best = -1;
223
+ // edits are rare but load-bearing: let them win the bucket color on ties
224
+ const priority: Action[] = ["edit", "verify", "read", "search", "exec", "other"];
225
+ for (const action of priority) {
226
+ const count = byAction.get(action) ?? 0;
227
+ if (count > best) {
228
+ best = count;
229
+ dominant = action;
230
+ }
231
+ }
232
+ out.push({ count: to - from, dominant });
233
+ }
234
+ return out;
235
+ }, [trace, total]);
236
+
237
+ const peak = useMemo(() => buckets.reduce((acc, b) => Math.max(acc, b.count), 1), [buckets]);
238
+
239
+ const markGroups = useMemo<MarkGroup[]>(() => {
240
+ if (!trace) return [];
241
+ const groups = new Map<string, MarkGroup>();
242
+ for (const mark of trace.marks) {
243
+ // tail marks can carry seq == events.length; clamp keeps them on the strip
244
+ const seqC = Math.min(mark.seq, max);
245
+ const pos = max > 0 ? seqC / max : 0;
246
+ const key = `${mark.type}:${Math.round(pos * MARK_SLOTS)}`;
247
+ const group = groups.get(key);
248
+ if (group) {
249
+ group.count++;
250
+ group.seq = Math.min(group.seq, seqC);
251
+ } else {
252
+ groups.set(key, { type: mark.type, seq: seqC, pos, count: 1, note: mark.note });
253
+ }
254
+ }
255
+ return [...groups.values()];
256
+ }, [trace, max]);
257
+
258
+ return (
259
+ <footer className="deck">
260
+ <div className="deck-main">
261
+ <div className="strip">
262
+ <div className="strip-marks">
263
+ {markGroups.map((group, i) => (
264
+ <button
265
+ type="button"
266
+ key={`${group.type}-${group.seq}-${i}`}
267
+ className={`strip-mark ${group.type}`}
268
+ style={{ left: `${group.pos * 100}%` }}
269
+ disabled={locked}
270
+ title={`${group.note || MARK_LABEL[group.type]}${group.count > 1 ? ` ×${group.count}` : ""}`}
271
+ aria-label={`Jump to ${group.note || MARK_LABEL[group.type]} at event ${group.seq + 1}${group.count > 1 ? `, ${group.count} marks` : ""}`}
272
+ onClick={() =>
273
+ group.type === "subagent" && onSubagentMark
274
+ ? onSubagentMark(group.seq)
275
+ : onChange(group.seq)
276
+ }
277
+ />
278
+ ))}
279
+ </div>
280
+ <div className="strip-bars" aria-hidden>
281
+ {buckets.map((bucket, i) => (
282
+ <span
283
+ key={i}
284
+ className={`strip-bar ${bucket.dominant}`}
285
+ style={{ height: `${18 + (bucket.count / peak) * 82}%` }}
286
+ />
287
+ ))}
288
+ </div>
289
+ {total > 0 ? (
290
+ <div className="strip-playhead" style={{ left: `${(seq / Math.max(max, 1)) * 100}%` }} aria-hidden />
291
+ ) : null}
292
+ <input
293
+ className="strip-input"
294
+ type="range"
295
+ min={0}
296
+ max={max}
297
+ value={seq}
298
+ disabled={locked}
299
+ onChange={(e) => onChange(Number(e.currentTarget.value))}
300
+ aria-label="Playback position"
301
+ aria-valuetext={event ? `event ${event.seq}: ${event.tool}` : "empty"}
302
+ />
303
+ </div>
304
+
305
+ <div className="deck-pos">
306
+ {/* reserve the widest count for this session ("599 / 599") so the
307
+ ticking digits never resize the strip beside them */}
308
+ <span className="deck-pos-count" style={{ minWidth: `${String(Math.max(total, 1)).length * 2 + 3}ch` }}>
309
+ {total > 0 ? `${seq + 1} / ${total}` : "0 / 0"}
310
+ </span>
311
+ <span className="deck-pos-clock">{event?.ts ? clock(event.ts) : "—"}</span>
312
+ </div>
313
+
314
+ <div className="transport">
315
+ <button
316
+ className="icon-btn"
317
+ onClick={() => step(-1)}
318
+ disabled={locked}
319
+ title="Step back (←)"
320
+ aria-label="Step back one event"
321
+ >
322
+ <StepBack size={15} />
323
+ </button>
324
+ <button
325
+ className="play-btn"
326
+ onClick={togglePlay}
327
+ disabled={locked}
328
+ title={playing ? "Pause (Space)" : "Play (Space)"}
329
+ aria-label={playing ? "Pause playback" : "Play playback"}
330
+ >
331
+ {playing ? <Pause size={16} /> : <Play size={16} className="play-glyph" />}
332
+ </button>
333
+ <button
334
+ className="icon-btn"
335
+ onClick={() => step(1)}
336
+ disabled={locked}
337
+ title="Step forward (→)"
338
+ aria-label="Step forward one event"
339
+ >
340
+ <StepForward size={15} />
341
+ </button>
342
+ <div className="transport-more" ref={menuRef}>
343
+ {/* the trigger doubles as status: recording spinner while an export
344
+ runs, the engaged speed when it isn't 1× */}
345
+ <button
346
+ className={`icon-btn${exporting ? " recording" : ""}${menuOpen ? " open" : ""}`}
347
+ onClick={() => setMenuOpen((v) => !v)}
348
+ disabled={locked}
349
+ aria-haspopup="true"
350
+ aria-expanded={menuOpen}
351
+ title="More controls"
352
+ aria-label="More playback controls"
353
+ >
354
+ {exporting ? (
355
+ <Loader size={15} className="spin" />
356
+ ) : speed !== 1 ? (
357
+ <span className="more-speed">{speed}×</span>
358
+ ) : (
359
+ <Ellipsis size={15} />
360
+ )}
361
+ </button>
362
+ {menuOpen ? (
363
+ <div className="transport-pop">
364
+ <div className="pop-speed">
365
+ <span className="pop-speed-label">Speed</span>
366
+ <div className="pop-speed-chips" title="Cycle with S">
367
+ {SPEEDS.map((s) => (
368
+ <button
369
+ key={s}
370
+ className={s === speed ? "pop-chip engaged" : "pop-chip"}
371
+ onClick={() => setSpeed(s)}
372
+ aria-pressed={s === speed}
373
+ >
374
+ {s}×
375
+ </button>
376
+ ))}
377
+ </div>
378
+ </div>
379
+ <button
380
+ className="pop-item"
381
+ onClick={() => {
382
+ onChange(0);
383
+ setMenuOpen(false);
384
+ }}
385
+ >
386
+ <RotateCcw size={14} />
387
+ <span>Restart</span>
388
+ <kbd>Home</kbd>
389
+ </button>
390
+ {onExport ? (
391
+ <button
392
+ className="pop-item"
393
+ onClick={() => {
394
+ setMenuOpen(false);
395
+ onExport();
396
+ }}
397
+ >
398
+ <Video size={14} />
399
+ <span>Export video</span>
400
+ </button>
401
+ ) : null}
402
+ </div>
403
+ ) : null}
404
+ </div>
405
+ </div>
406
+ </div>
407
+
408
+ <div className="deck-foot">
409
+ <div className="readout-now">
410
+ {event ? (
411
+ <>
412
+ <span className={`action-dot ${event.action}`} />
413
+ <span className="readout-tool">{event.tool}</span>
414
+ {event.isError ? <span className="err">error</span> : null}
415
+ <span className="readout-summary" title={event.summary}>
416
+ {event.summary}
417
+ </span>
418
+ </>
419
+ ) : (
420
+ <span className="readout-summary">
421
+ {trace ? "No recorded activity for this agent." : "Select a session to start the walk."}
422
+ </span>
423
+ )}
424
+ </div>
425
+ <div className="deck-legend" aria-hidden>
426
+ <span className="legend-group">
427
+ {STRIP_ACTIONS.map((action) => (
428
+ <span key={action} className="legend-item">
429
+ <span className={`action-dot ${action}`} />
430
+ {action}
431
+ </span>
432
+ ))}
433
+ </span>
434
+ <span className="legend-group">
435
+ <span className="legend-item">
436
+ <span className="legend-glyph compaction" />
437
+ compaction
438
+ </span>
439
+ <span className="legend-item">
440
+ <span className="legend-glyph subagent" />
441
+ subagent
442
+ </span>
443
+ <span className="legend-item">
444
+ <span className="legend-glyph user-message" />
445
+ user turn
446
+ </span>
447
+ </span>
448
+ </div>
449
+ </div>
450
+ </footer>
451
+ );
452
+ }
453
+
454
+ function clock(iso: string): string {
455
+ const d = new Date(iso);
456
+ if (Number.isNaN(d.getTime())) return "";
457
+ return [d.getHours(), d.getMinutes(), d.getSeconds()].map((n) => String(n).padStart(2, "0")).join(":");
458
+ }
@@ -0,0 +1,45 @@
1
+ import { Mountain, TreePine } from "lucide-react";
2
+ import type { SceneView } from "../state/store";
3
+
4
+ interface ViewPanelProps {
5
+ view: SceneView;
6
+ onViewChange: (view: SceneView) => void;
7
+ /** the legend line for the active view ("glow ∝ revisits", …) */
8
+ note: string;
9
+ /** during video export the scene canvas must not be swapped */
10
+ locked?: boolean;
11
+ }
12
+
13
+ // pop content for the dock's scene section: how the stage renders. Future
14
+ // layer toggles (line-level lights, trail length, floor contrast) join this
15
+ // panel instead of growing the strip.
16
+ export function ViewPanel({ view, onViewChange, note, locked = false }: ViewPanelProps) {
17
+ return (
18
+ // toggle buttons rather than a radiogroup: the full radio pattern would
19
+ // demand roving tabindex + arrow-key navigation these buttons don't have
20
+ <div className="view-pop" role="group" aria-label="Scene view">
21
+ <button
22
+ aria-pressed={view === "tree"}
23
+ className={view === "tree" ? "view-row active" : "view-row"}
24
+ onClick={() => onViewChange("tree")}
25
+ disabled={locked}
26
+ >
27
+ <TreePine size={14} />
28
+ <span>Tree</span>
29
+ </button>
30
+ <button
31
+ aria-pressed={view === "terrain"}
32
+ className={view === "terrain" ? "view-row active" : "view-row"}
33
+ onClick={() => onViewChange("terrain")}
34
+ disabled={locked}
35
+ >
36
+ <Mountain size={14} />
37
+ <span>Terrain</span>
38
+ </button>
39
+ <p className="view-note">
40
+ {note}
41
+ <span className="view-key">V</span>
42
+ </p>
43
+ </div>
44
+ );
45
+ }
@@ -0,0 +1,4 @@
1
+ // ⌘B on macOS, Ctrl+B elsewhere — the toggle lives in App's keydown handler
2
+ const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
3
+
4
+ export const toggleRailShortcut = isMac ? "⌘B" : "Ctrl+B";
@@ -0,0 +1,21 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
6
+ "allowJs": false,
7
+ "skipLibCheck": true,
8
+ "esModuleInterop": true,
9
+ "allowSyntheticDefaultImports": true,
10
+ "strict": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "module": "ESNext",
13
+ "moduleResolution": "Bundler",
14
+ "resolveJsonModule": true,
15
+ "isolatedModules": true,
16
+ "noEmit": true,
17
+ "jsx": "react-jsx"
18
+ },
19
+ "include": ["src"],
20
+ "references": []
21
+ }
@@ -0,0 +1,28 @@
1
+ import { defineConfig } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ server: {
7
+ host: "127.0.0.1",
8
+ port: 5173,
9
+ proxy: {
10
+ "/api": "http://127.0.0.1:8765"
11
+ }
12
+ },
13
+ build: {
14
+ sourcemap: true,
15
+ // three.js alone minifies to ~510 kB (~130 kB gzip) and cannot be split
16
+ // further, so give it its own chunk and lift the warning limit above it
17
+ chunkSizeWarningLimit: 600,
18
+ rollupOptions: {
19
+ output: {
20
+ manualChunks(id) {
21
+ if (id.includes("node_modules/three/")) return "three";
22
+ if (/node_modules\/(react|react-dom|scheduler)\//.test(id)) return "react";
23
+ return undefined;
24
+ }
25
+ }
26
+ }
27
+ }
28
+ });