backpack-viewer 0.2.16 → 0.2.17

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.
package/dist/main.js CHANGED
@@ -4,15 +4,27 @@ import { initCanvas } from "./canvas";
4
4
  import { initInfoPanel } from "./info-panel";
5
5
  import { initSearch } from "./search";
6
6
  import { initToolsPane } from "./tools-pane";
7
- import { setLayoutParams } from "./layout";
7
+ import { setLayoutParams, getLayoutParams, autoLayoutParams } from "./layout";
8
8
  import { initShortcuts } from "./shortcuts";
9
9
  import { initEmptyState } from "./empty-state";
10
10
  import { createHistory } from "./history";
11
+ import { matchKey } from "./keybindings";
12
+ import defaultConfig from "./default-config.json";
11
13
  import "./style.css";
12
14
  let activeOntology = "";
13
15
  let currentData = null;
14
16
  async function main() {
15
17
  const canvasContainer = document.getElementById("canvas-container");
18
+ // --- Load config (keybindings) ---
19
+ let bindings = defaultConfig.keybindings;
20
+ try {
21
+ const res = await fetch("/api/config");
22
+ if (res.ok) {
23
+ const config = await res.json();
24
+ bindings = { ...bindings, ...(config.keybindings ?? {}) };
25
+ }
26
+ }
27
+ catch { /* use defaults */ }
16
28
  // --- Theme toggle (top-right of canvas) ---
17
29
  const prefersDark = window.matchMedia("(prefers-color-scheme: dark)");
18
30
  const stored = localStorage.getItem("backpack-theme");
@@ -126,6 +138,9 @@ async function main() {
126
138
  const mobileQuery = window.matchMedia("(max-width: 768px)");
127
139
  // Track current selection for keyboard shortcuts
128
140
  let currentSelection = [];
141
+ let edgesVisible = true;
142
+ let panSpeed = 60;
143
+ let viewCycleIndex = -1;
129
144
  // --- Focus indicator (top bar pill) ---
130
145
  let focusIndicator = null;
131
146
  function buildFocusIndicator(info) {
@@ -188,14 +203,15 @@ async function main() {
188
203
  }, (focus) => {
189
204
  if (focus) {
190
205
  buildFocusIndicator(focus);
191
- // Insert into top-left, after tools toggle
192
206
  const topLeft = canvasContainer.querySelector(".canvas-top-left");
193
207
  if (topLeft && focusIndicator)
194
208
  topLeft.appendChild(focusIndicator);
195
209
  updateUrl(activeOntology, focus.seedNodeIds);
210
+ infoPanel.setFocusDisabled(focus.hops === 0);
196
211
  }
197
212
  else {
198
213
  removeFocusIndicator();
214
+ infoPanel.setFocusDisabled(false);
199
215
  if (activeOntology)
200
216
  updateUrl(activeOntology);
201
217
  }
@@ -222,7 +238,7 @@ async function main() {
222
238
  },
223
239
  onFocusChange(seedNodeIds) {
224
240
  if (seedNodeIds && seedNodeIds.length > 0) {
225
- canvas.enterFocus(seedNodeIds, 1);
241
+ canvas.enterFocus(seedNodeIds, 0);
226
242
  }
227
243
  else {
228
244
  if (canvas.isFocused())
@@ -265,6 +281,9 @@ async function main() {
265
281
  setLayoutParams({ [param]: value });
266
282
  canvas.reheat();
267
283
  },
284
+ onPanSpeedChange(speed) {
285
+ panSpeed = speed;
286
+ },
268
287
  onExport(format) {
269
288
  const dataUrl = canvas.exportImage(format);
270
289
  if (!dataUrl)
@@ -336,7 +355,7 @@ async function main() {
336
355
  }
337
356
  },
338
357
  });
339
- const shortcuts = initShortcuts(canvasContainer);
358
+ const shortcuts = initShortcuts(canvasContainer, bindings);
340
359
  const emptyState = initEmptyState(canvasContainer);
341
360
  // --- URL deep linking ---
342
361
  function updateUrl(name, nodeIds) {
@@ -384,6 +403,7 @@ async function main() {
384
403
  search.clear();
385
404
  undoHistory.clear();
386
405
  currentData = await loadOntology(name);
406
+ setLayoutParams(autoLayoutParams(currentData.nodes.length));
387
407
  canvas.loadGraph(currentData);
388
408
  search.setLearningGraphData(currentData);
389
409
  toolsPane.setData(currentData);
@@ -428,48 +448,88 @@ async function main() {
428
448
  else {
429
449
  emptyState.show();
430
450
  }
431
- // Keyboard shortcuts
432
- document.addEventListener("keydown", (e) => {
433
- if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
434
- return;
435
- if (e.key === "/" || (e.key === "k" && (e.metaKey || e.ctrlKey))) {
436
- e.preventDefault();
437
- search.focus();
438
- }
439
- else if (e.key === "z" && (e.metaKey || e.ctrlKey) && e.shiftKey) {
440
- e.preventDefault();
441
- if (currentData) {
442
- const restored = undoHistory.redo(currentData);
443
- if (restored)
444
- applyState(restored);
445
- }
446
- }
447
- else if (e.key === "z" && (e.metaKey || e.ctrlKey)) {
448
- e.preventDefault();
449
- if (currentData) {
450
- const restored = undoHistory.undo(currentData);
451
- if (restored)
452
- applyState(restored);
453
- }
454
- }
455
- else if (e.key === "f" || e.key === "F") {
456
- // Toggle focus mode on current selection
451
+ // Keyboard shortcuts — dispatched via configurable bindings
452
+ const actions = {
453
+ search() { search.focus(); },
454
+ searchAlt() { search.focus(); },
455
+ undo() { if (currentData) {
456
+ const r = undoHistory.undo(currentData);
457
+ if (r)
458
+ applyState(r);
459
+ } },
460
+ redo() { if (currentData) {
461
+ const r = undoHistory.redo(currentData);
462
+ if (r)
463
+ applyState(r);
464
+ } },
465
+ focus() {
457
466
  if (canvas.isFocused()) {
458
467
  toolsPane.clearFocusSet();
459
468
  }
460
469
  else if (currentSelection.length > 0) {
461
470
  toolsPane.addToFocusSet(currentSelection);
462
471
  }
463
- }
464
- else if (e.key === "?") {
465
- shortcuts.show();
466
- }
467
- else if (e.key === "Escape") {
468
- if (canvas.isFocused()) {
469
- toolsPane.clearFocusSet();
472
+ },
473
+ hopsDecrease() { const i = canvas.getFocusInfo(); if (i && i.hops > 0)
474
+ canvas.enterFocus(i.seedNodeIds, i.hops - 1); },
475
+ hopsIncrease() { const i = canvas.getFocusInfo(); if (i)
476
+ canvas.enterFocus(i.seedNodeIds, i.hops + 1); },
477
+ nextNode() {
478
+ const ids = canvas.getNodeIds();
479
+ if (ids.length > 0) {
480
+ viewCycleIndex = (viewCycleIndex + 1) % ids.length;
481
+ canvas.panToNode(ids[viewCycleIndex]);
482
+ if (currentData)
483
+ infoPanel.show([ids[viewCycleIndex]], currentData);
470
484
  }
471
- else {
472
- shortcuts.hide();
485
+ },
486
+ prevNode() {
487
+ const ids = canvas.getNodeIds();
488
+ if (ids.length > 0) {
489
+ viewCycleIndex = viewCycleIndex <= 0 ? ids.length - 1 : viewCycleIndex - 1;
490
+ canvas.panToNode(ids[viewCycleIndex]);
491
+ if (currentData)
492
+ infoPanel.show([ids[viewCycleIndex]], currentData);
493
+ }
494
+ },
495
+ nextConnection() { const id = infoPanel.cycleConnection(1); if (id)
496
+ canvas.panToNode(id); },
497
+ prevConnection() { const id = infoPanel.cycleConnection(-1); if (id)
498
+ canvas.panToNode(id); },
499
+ historyBack() { infoPanel.goBack(); },
500
+ historyForward() { infoPanel.goForward(); },
501
+ center() { canvas.centerView(); },
502
+ toggleEdges() { edgesVisible = !edgesVisible; canvas.setEdges(edgesVisible); },
503
+ panLeft() { canvas.panBy(-panSpeed, 0); },
504
+ panDown() { canvas.panBy(0, panSpeed); },
505
+ panUp() { canvas.panBy(0, -panSpeed); },
506
+ panRight() { canvas.panBy(panSpeed, 0); },
507
+ panFastLeft() { canvas.panBy(-panSpeed * 3, 0); },
508
+ zoomOut() { canvas.zoomBy(0.8); },
509
+ zoomIn() { canvas.zoomBy(1.25); },
510
+ panFastRight() { canvas.panBy(panSpeed * 3, 0); },
511
+ spacingDecrease() { const p = getLayoutParams(); setLayoutParams({ spacing: Math.max(0.5, p.spacing - 0.5) }); canvas.reheat(); },
512
+ spacingIncrease() { const p = getLayoutParams(); setLayoutParams({ spacing: Math.min(20, p.spacing + 0.5) }); canvas.reheat(); },
513
+ clusteringDecrease() { const p = getLayoutParams(); setLayoutParams({ clusterStrength: Math.max(0, p.clusterStrength - 0.03) }); canvas.reheat(); },
514
+ clusteringIncrease() { const p = getLayoutParams(); setLayoutParams({ clusterStrength: Math.min(1, p.clusterStrength + 0.03) }); canvas.reheat(); },
515
+ help() { shortcuts.toggle(); },
516
+ escape() { if (canvas.isFocused()) {
517
+ toolsPane.clearFocusSet();
518
+ }
519
+ else {
520
+ shortcuts.hide();
521
+ } },
522
+ };
523
+ document.addEventListener("keydown", (e) => {
524
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
525
+ return;
526
+ for (const [action, binding] of Object.entries(bindings)) {
527
+ if (matchKey(e, binding)) {
528
+ const needsPrevent = action === "search" || action === "searchAlt" || action === "undo" || action === "redo";
529
+ if (needsPrevent)
530
+ e.preventDefault();
531
+ actions[action]?.();
532
+ return;
473
533
  }
474
534
  }
475
535
  });
package/dist/search.js CHANGED
@@ -10,13 +10,10 @@ function nodeLabel(node) {
10
10
  /** Check if a node matches a search query (case-insensitive across label + all string properties). */
11
11
  function matchesQuery(node, query) {
12
12
  const q = query.toLowerCase();
13
- // Check label
14
13
  if (nodeLabel(node).toLowerCase().includes(q))
15
14
  return true;
16
- // Check type
17
15
  if (node.type.toLowerCase().includes(q))
18
16
  return true;
19
- // Check all string property values
20
17
  for (const value of Object.values(node.properties)) {
21
18
  if (typeof value === "string" && value.toLowerCase().includes(q))
22
19
  return true;
@@ -27,7 +24,6 @@ export function initSearch(container) {
27
24
  let data = null;
28
25
  let filterCallback = null;
29
26
  let selectCallback = null;
30
- let activeTypes = new Set();
31
27
  let debounceTimer = null;
32
28
  // --- DOM ---
33
29
  const overlay = document.createElement("div");
@@ -43,83 +39,24 @@ export function initSearch(container) {
43
39
  const kbd = document.createElement("kbd");
44
40
  kbd.className = "search-kbd";
45
41
  kbd.textContent = "/";
46
- const chipToggle = document.createElement("button");
47
- chipToggle.className = "chip-toggle";
48
- chipToggle.setAttribute("aria-label", "Toggle filter chips");
49
- chipToggle.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>';
50
- let chipsVisible = false;
51
- chipToggle.addEventListener("click", () => {
52
- chipsVisible = !chipsVisible;
53
- chips.classList.toggle("hidden", !chipsVisible);
54
- chipToggle.classList.toggle("active", chipsVisible);
55
- });
56
42
  inputWrap.appendChild(input);
57
43
  inputWrap.appendChild(kbd);
58
- inputWrap.appendChild(chipToggle);
59
44
  const results = document.createElement("ul");
60
45
  results.className = "search-results hidden";
61
- const chips = document.createElement("div");
62
- chips.className = "type-chips hidden";
63
46
  overlay.appendChild(inputWrap);
64
47
  overlay.appendChild(results);
65
- overlay.appendChild(chips);
66
48
  container.appendChild(overlay);
67
- // --- Type chips ---
68
- function buildChips() {
69
- chips.innerHTML = "";
70
- if (!data)
71
- return;
72
- // Count nodes per type
73
- const typeCounts = new Map();
74
- for (const node of data.nodes) {
75
- typeCounts.set(node.type, (typeCounts.get(node.type) ?? 0) + 1);
76
- }
77
- // Sort alphabetically
78
- const types = [...typeCounts.keys()].sort();
79
- activeTypes = new Set(); // None selected = show all
80
- for (const type of types) {
81
- const chip = document.createElement("button");
82
- chip.className = "type-chip";
83
- chip.dataset.type = type;
84
- const dot = document.createElement("span");
85
- dot.className = "type-chip-dot";
86
- dot.style.backgroundColor = getColor(type);
87
- const label = document.createElement("span");
88
- label.textContent = `${type} (${typeCounts.get(type)})`;
89
- chip.appendChild(dot);
90
- chip.appendChild(label);
91
- chip.addEventListener("click", () => {
92
- if (activeTypes.has(type)) {
93
- activeTypes.delete(type);
94
- chip.classList.remove("active");
95
- }
96
- else {
97
- activeTypes.add(type);
98
- chip.classList.add("active");
99
- }
100
- applyFilter();
101
- });
102
- chips.appendChild(chip);
103
- }
104
- }
105
- // --- Search + filter logic ---
49
+ // --- Search logic ---
106
50
  function getMatchingIds() {
107
51
  if (!data)
108
52
  return null;
109
53
  const query = input.value.trim();
110
- const noChipsSelected = activeTypes.size === 0;
111
- const noQuery = query.length === 0;
112
- // No filter active — return null (show all)
113
- if (noQuery && noChipsSelected)
54
+ if (query.length === 0)
114
55
  return null;
115
56
  const ids = new Set();
116
57
  for (const node of data.nodes) {
117
- // If chips are selected, only include those types
118
- if (!noChipsSelected && !activeTypes.has(node.type))
119
- continue;
120
- if (noQuery || matchesQuery(node, query)) {
58
+ if (matchesQuery(node, query))
121
59
  ids.add(node.id);
122
- }
123
60
  }
124
61
  return ids;
125
62
  }
@@ -130,16 +67,14 @@ export function initSearch(container) {
130
67
  }
131
68
  function updateResults() {
132
69
  results.innerHTML = "";
70
+ activeIndex = -1;
133
71
  const query = input.value.trim();
134
72
  if (!data || query.length === 0) {
135
73
  results.classList.add("hidden");
136
74
  return;
137
75
  }
138
- const noChipsSelected = activeTypes.size === 0;
139
76
  const matches = [];
140
77
  for (const node of data.nodes) {
141
- if (!noChipsSelected && !activeTypes.has(node.type))
142
- continue;
143
78
  if (matchesQuery(node, query)) {
144
79
  matches.push(node);
145
80
  if (matches.length >= 8)
@@ -182,26 +117,55 @@ export function initSearch(container) {
182
117
  clearTimeout(debounceTimer);
183
118
  debounceTimer = setTimeout(applyFilter, 150);
184
119
  });
120
+ let activeIndex = -1;
121
+ function updateActiveResult() {
122
+ const items = results.querySelectorAll(".search-result-item");
123
+ items.forEach((el, i) => {
124
+ el.classList.toggle("search-result-active", i === activeIndex);
125
+ });
126
+ if (activeIndex >= 0 && items[activeIndex]) {
127
+ items[activeIndex].scrollIntoView({ block: "nearest" });
128
+ }
129
+ }
185
130
  input.addEventListener("keydown", (e) => {
186
- if (e.key === "Escape") {
131
+ const items = results.querySelectorAll(".search-result-item");
132
+ if (e.key === "ArrowDown") {
133
+ e.preventDefault();
134
+ if (items.length > 0) {
135
+ activeIndex = Math.min(activeIndex + 1, items.length - 1);
136
+ updateActiveResult();
137
+ }
138
+ }
139
+ else if (e.key === "ArrowUp") {
140
+ e.preventDefault();
141
+ if (items.length > 0) {
142
+ activeIndex = Math.max(activeIndex - 1, 0);
143
+ updateActiveResult();
144
+ }
145
+ }
146
+ else if (e.key === "Enter") {
147
+ e.preventDefault();
148
+ if (activeIndex >= 0 && items[activeIndex]) {
149
+ items[activeIndex].click();
150
+ }
151
+ else if (items.length > 0) {
152
+ items[0].click();
153
+ }
154
+ input.blur();
155
+ }
156
+ else if (e.key === "Escape") {
187
157
  input.value = "";
188
158
  input.blur();
189
159
  results.classList.add("hidden");
160
+ activeIndex = -1;
190
161
  applyFilter();
191
162
  }
192
- else if (e.key === "Enter") {
193
- // Select first result
194
- const first = results.querySelector(".search-result-item");
195
- first?.click();
196
- }
197
163
  });
198
- // Close results when clicking outside
199
164
  document.addEventListener("click", (e) => {
200
165
  if (!overlay.contains(e.target)) {
201
166
  results.classList.add("hidden");
202
167
  }
203
168
  });
204
- // Hide kbd hint when focused
205
169
  input.addEventListener("focus", () => kbd.classList.add("hidden"));
206
170
  input.addEventListener("blur", () => {
207
171
  if (input.value.length === 0)
@@ -215,7 +179,6 @@ export function initSearch(container) {
215
179
  results.classList.add("hidden");
216
180
  if (data && data.nodes.length > 0) {
217
181
  overlay.classList.remove("hidden");
218
- buildChips();
219
182
  }
220
183
  else {
221
184
  overlay.classList.add("hidden");
@@ -230,10 +193,6 @@ export function initSearch(container) {
230
193
  clear() {
231
194
  input.value = "";
232
195
  results.classList.add("hidden");
233
- activeTypes.clear();
234
- chipsVisible = false;
235
- chips.classList.add("hidden");
236
- chipToggle.classList.remove("active");
237
196
  filterCallback?.(null);
238
197
  },
239
198
  focus() {
@@ -1,4 +1,6 @@
1
- export declare function initShortcuts(container: HTMLElement): {
1
+ import { type KeybindingMap } from "./keybindings";
2
+ export declare function initShortcuts(container: HTMLElement, bindings: KeybindingMap): {
2
3
  show: () => void;
3
4
  hide: () => void;
5
+ toggle: () => void;
4
6
  };
package/dist/shortcuts.js CHANGED
@@ -1,16 +1,33 @@
1
- const SHORTCUTS = [
2
- { key: "/", alt: "Ctrl+K", description: "Focus search" },
3
- { key: "Ctrl+Z", description: "Undo" },
4
- { key: "Ctrl+Shift+Z", description: "Redo" },
5
- { key: "?", description: "Show this help" },
6
- { key: "F", description: "Focus on selected / exit focus" },
7
- { key: "Esc", description: "Exit focus / close panel" },
1
+ import { actionDescriptions } from "./keybindings";
2
+ // Non-keyboard actions shown at the bottom of help
3
+ const MOUSE_ACTIONS = [
8
4
  { key: "Click", description: "Select node" },
9
5
  { key: "Ctrl+Click", description: "Multi-select nodes" },
10
6
  { key: "Drag", description: "Pan canvas" },
11
7
  { key: "Scroll", description: "Zoom in/out" },
12
8
  ];
13
- export function initShortcuts(container) {
9
+ // Group and order for display
10
+ const ACTION_ORDER = [
11
+ "search", "searchAlt", "undo", "redo", "help",
12
+ "focus", "toggleEdges", "center",
13
+ "nextNode", "prevNode", "nextConnection", "prevConnection",
14
+ "historyBack", "historyForward",
15
+ "hopsIncrease", "hopsDecrease",
16
+ "panLeft", "panDown", "panUp", "panRight",
17
+ "panFastLeft", "panFastRight", "zoomIn", "zoomOut",
18
+ "spacingDecrease", "spacingIncrease",
19
+ "clusteringDecrease", "clusteringIncrease",
20
+ "escape",
21
+ ];
22
+ /** Format a binding string for display (e.g. "ctrl+z" → "Ctrl+Z"). */
23
+ function formatBinding(binding) {
24
+ return binding
25
+ .split("+")
26
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
27
+ .join("+");
28
+ }
29
+ export function initShortcuts(container, bindings) {
30
+ const descriptions = actionDescriptions();
14
31
  const overlay = document.createElement("div");
15
32
  overlay.className = "shortcuts-overlay hidden";
16
33
  const modal = document.createElement("div");
@@ -20,7 +37,27 @@ export function initShortcuts(container) {
20
37
  title.textContent = "Keyboard Shortcuts";
21
38
  const list = document.createElement("div");
22
39
  list.className = "shortcuts-list";
23
- for (const s of SHORTCUTS) {
40
+ // Keybinding actions
41
+ for (const action of ACTION_ORDER) {
42
+ const binding = bindings[action];
43
+ if (!binding)
44
+ continue;
45
+ const row = document.createElement("div");
46
+ row.className = "shortcuts-row";
47
+ const keys = document.createElement("div");
48
+ keys.className = "shortcuts-keys";
49
+ const kbd = document.createElement("kbd");
50
+ kbd.textContent = formatBinding(binding);
51
+ keys.appendChild(kbd);
52
+ const desc = document.createElement("span");
53
+ desc.className = "shortcuts-desc";
54
+ desc.textContent = descriptions[action];
55
+ row.appendChild(keys);
56
+ row.appendChild(desc);
57
+ list.appendChild(row);
58
+ }
59
+ // Mouse actions
60
+ for (const s of MOUSE_ACTIONS) {
24
61
  const row = document.createElement("div");
25
62
  row.className = "shortcuts-row";
26
63
  const keys = document.createElement("div");
@@ -28,15 +65,6 @@ export function initShortcuts(container) {
28
65
  const kbd = document.createElement("kbd");
29
66
  kbd.textContent = s.key;
30
67
  keys.appendChild(kbd);
31
- if (s.alt) {
32
- const or = document.createElement("span");
33
- or.className = "shortcuts-or";
34
- or.textContent = "or";
35
- keys.appendChild(or);
36
- const kbd2 = document.createElement("kbd");
37
- kbd2.textContent = s.alt;
38
- keys.appendChild(kbd2);
39
- }
40
68
  const desc = document.createElement("span");
41
69
  desc.className = "shortcuts-desc";
42
70
  desc.textContent = s.description;
@@ -58,10 +86,13 @@ export function initShortcuts(container) {
58
86
  function hide() {
59
87
  overlay.classList.add("hidden");
60
88
  }
89
+ function toggle() {
90
+ overlay.classList.toggle("hidden");
91
+ }
61
92
  closeBtn.addEventListener("click", hide);
62
93
  overlay.addEventListener("click", (e) => {
63
94
  if (e.target === overlay)
64
95
  hide();
65
96
  });
66
- return { show, hide };
97
+ return { show, hide, toggle };
67
98
  }
package/dist/style.css CHANGED
@@ -269,6 +269,10 @@ body {
269
269
  gap: 4px;
270
270
  }
271
271
 
272
+ .canvas-top-left {
273
+ flex-shrink: 0;
274
+ }
275
+
272
276
  .canvas-top-center {
273
277
  flex: 1;
274
278
  justify-content: center;
@@ -510,7 +514,8 @@ body {
510
514
  transition: background 0.1s;
511
515
  }
512
516
 
513
- .search-result-item:hover {
517
+ .search-result-item:hover,
518
+ .search-result-active {
514
519
  background: var(--bg-hover);
515
520
  }
516
521
 
@@ -837,6 +842,11 @@ body {
837
842
  flex-wrap: wrap;
838
843
  }
839
844
 
845
+ .info-connection-active {
846
+ outline: 1.5px solid var(--accent);
847
+ background: var(--bg-hover);
848
+ }
849
+
840
850
  .info-target-dot {
841
851
  width: 8px;
842
852
  height: 8px;
@@ -1071,6 +1081,7 @@ body {
1071
1081
  z-index: 20;
1072
1082
  width: 200px;
1073
1083
  overflow-y: auto;
1084
+ overflow-x: hidden;
1074
1085
  background: var(--bg-surface);
1075
1086
  border: 1px solid var(--border);
1076
1087
  border-radius: 10px;
@@ -1296,6 +1307,68 @@ body {
1296
1307
  padding: 8px 0;
1297
1308
  }
1298
1309
 
1310
+ .tools-pane-tabs {
1311
+ display: flex;
1312
+ gap: 2px;
1313
+ margin-bottom: 10px;
1314
+ padding-bottom: 8px;
1315
+ border-bottom: 1px solid var(--border);
1316
+ }
1317
+
1318
+ .tools-pane-tab {
1319
+ flex: 1;
1320
+ padding: 4px 0;
1321
+ font-size: 10px;
1322
+ font-weight: 600;
1323
+ text-transform: uppercase;
1324
+ letter-spacing: 0.03em;
1325
+ background: none;
1326
+ border: 1px solid transparent;
1327
+ border-radius: 5px;
1328
+ color: var(--text-dim);
1329
+ cursor: pointer;
1330
+ transition: color 0.15s, background 0.15s, border-color 0.15s;
1331
+ }
1332
+
1333
+ .tools-pane-tab:hover {
1334
+ color: var(--text-muted);
1335
+ background: var(--bg-hover);
1336
+ }
1337
+
1338
+ .tools-pane-tab-active {
1339
+ color: var(--text);
1340
+ background: var(--bg-hover);
1341
+ border-color: var(--border);
1342
+ }
1343
+
1344
+ .tools-pane-search {
1345
+ width: 100%;
1346
+ padding: 4px 8px;
1347
+ font-size: 11px;
1348
+ background: var(--bg);
1349
+ border: 1px solid var(--border);
1350
+ border-radius: 6px;
1351
+ color: var(--text);
1352
+ outline: none;
1353
+ margin-bottom: 8px;
1354
+ box-sizing: border-box;
1355
+ }
1356
+
1357
+ .tools-pane-search:focus {
1358
+ border-color: var(--accent);
1359
+ }
1360
+
1361
+ .tools-pane-search::placeholder {
1362
+ color: var(--text-dim);
1363
+ }
1364
+
1365
+ .tools-pane-empty-msg {
1366
+ font-size: 11px;
1367
+ color: var(--text-dim);
1368
+ text-align: center;
1369
+ padding: 16px 0;
1370
+ }
1371
+
1299
1372
  /* --- Empty State --- */
1300
1373
 
1301
1374
  .empty-state {
@@ -9,6 +9,7 @@ interface ToolsPaneCallbacks {
9
9
  onToggleTypeHulls: (visible: boolean) => void;
10
10
  onToggleMinimap: (visible: boolean) => void;
11
11
  onLayoutChange: (param: string, value: number) => void;
12
+ onPanSpeedChange: (speed: number) => void;
12
13
  onExport: (format: "png" | "svg") => void;
13
14
  onOpen?: () => void;
14
15
  }