@selvajs/ui 6.6.0-beta.11 → 6.6.0-beta.13

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.
@@ -420,7 +420,7 @@
420
420
  {/if}
421
421
  {:else if !hasViewer && !isTwoPanelMode && hasLeftPanel !== hasRightPanel}
422
422
  <!-- Single centered panel (left-only or right-only), no viewer -->
423
- <div class="min-h-0 flex h-full flex-1 justify-center">
423
+ <div class="min-h-0 p-1 flex h-full flex-1 justify-center">
424
424
  <div class="min-h-0 flex h-full w-4/5 flex-col">
425
425
  {@render panelContent(
426
426
  hasRightPanel ? 'right' : undefined,
@@ -447,7 +447,7 @@
447
447
  <Resizable.PaneGroup
448
448
  direction="horizontal"
449
449
  autoSaveId="selva-layout-{layoutKey}"
450
- class="min-h-0 flex-1"
450
+ class="min-h-0 m-1 flex-1"
451
451
  >
452
452
  {#if hasLeftPanel}
453
453
  <Resizable.Pane
@@ -460,7 +460,6 @@
460
460
  collapsedSize={0}
461
461
  onCollapse={() => (leftCollapsed = true)}
462
462
  onExpand={() => (leftCollapsed = false)}
463
- class="ml-1"
464
463
  >
465
464
  <div
466
465
  class="min-h-0 flex h-full flex-col {isViewerFullscreen || leftCollapsed
@@ -481,7 +480,7 @@
481
480
  {/if}
482
481
 
483
482
  {#if hasViewer}
484
- <Resizable.Pane order={2} minSize={20} class="min-h-0 mx-1 flex flex-col">
483
+ <Resizable.Pane order={2} minSize={20} class="min-h-0 flex flex-col">
485
484
  <Viewer
486
485
  {meshes}
487
486
  bind:isFullscreen={isViewerFullscreen}
@@ -498,14 +497,16 @@
498
497
  {/if}
499
498
 
500
499
  {#if hasRightPanel}
501
- <Resizable.Handle
502
- withHandle
503
- line={false}
504
- data-collapsed={rightCollapsed}
505
- onclick={() => {
506
- if (rightCollapsed) rightPaneRef?.expand();
507
- }}
508
- />
500
+ {#if hasViewer || !hasLeftPanel}
501
+ <Resizable.Handle
502
+ withHandle
503
+ line={false}
504
+ data-collapsed={rightCollapsed}
505
+ onclick={() => {
506
+ if (rightCollapsed) rightPaneRef?.expand();
507
+ }}
508
+ />
509
+ {/if}
509
510
  <Resizable.Pane
510
511
  bind:this={rightPaneRef}
511
512
  order={3}
@@ -516,7 +517,6 @@
516
517
  collapsedSize={0}
517
518
  onCollapse={() => (rightCollapsed = true)}
518
519
  onExpand={() => (rightCollapsed = false)}
519
- class="mr-1"
520
520
  >
521
521
  <div
522
522
  class="min-h-0 flex h-full flex-col {isViewerFullscreen || rightCollapsed
@@ -20,12 +20,14 @@
20
20
  [
21
21
  'gap-1 py-2 lg:py-3 lg:flex-col lg:w-auto px-2 lg:px-0',
22
22
  'flex w-full shrink-0 flex-row items-center',
23
- 'backdrop-blur-sm bg-background/90 cursor-pointer'
23
+ 'backdrop-blur-sm bg-background/90 cursor-pointer',
24
+ side === 'left' ? 'lg:border-r' : 'lg:border-l',
25
+ 'border-border'
24
26
  ].join(' ')
25
27
  );
26
28
 
27
29
  const tabButtonClass =
28
- 'w-8 h-8 rounded cursor-pointer flex shrink-0 items-center justify-center text-muted-foreground transition-colors hover:bg-accent hover:text-foreground';
30
+ 'w-8 h-8 rounded-md cursor-pointer flex shrink-0 items-center justify-center text-muted-foreground transition-colors hover:bg-accent hover:text-foreground';
29
31
  </script>
30
32
 
31
33
  <div
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { OutputChartLayoutItem } from '@selvajs/schemas';
3
- import { onMount } from 'svelte';
3
+ import { onMount, tick } from 'svelte';
4
+ import { mode } from 'mode-watcher';
4
5
  import { Loader, Maximize, Minimize } from '@lucide/svelte';
5
6
  import { loadScript } from '../../utils/loadScript';
6
7
  import { getLocaleContext } from '../../i18n/localeContext.svelte';
@@ -57,6 +58,19 @@
57
58
  // Some embedded WebViews (WKWebView in the Rhino panel) ship without the Fullscreen API at all.
58
59
  const fullscreenSupported = typeof document !== 'undefined' && !!document.fullscreenEnabled;
59
60
 
61
+ // `Plots.resize()`/`relayout({autosize: true})` ask Plotly to re-measure the container itself,
62
+ // which it silently fails to do across a Fullscreen transition — _fullLayout keeps the
63
+ // pre-toggle width/height even though clientWidth/clientHeight are already correct. Passing
64
+ // the measured size explicitly is the only call that actually takes.
65
+ function resizeChart() {
66
+ // @ts-expect-error — Plotly is loaded dynamically onto window
67
+ if (!containerEl || !window.Plotly) return;
68
+ const { width, height } = containerEl.getBoundingClientRect();
69
+ if (width === 0 || height === 0) return;
70
+ // @ts-expect-error — Plotly is loaded dynamically onto window
71
+ window.Plotly.relayout(containerEl, { width, height });
72
+ }
73
+
60
74
  function toggleFullscreen() {
61
75
  if (!wrapperEl) return;
62
76
  if (!document.fullscreenElement) {
@@ -73,26 +87,41 @@
73
87
  $effect(() => {
74
88
  function onFullscreenChange() {
75
89
  isFullscreen = !!document.fullscreenElement;
76
- Promise.resolve().then(() => {
77
- // @ts-expect-error — Plotly is loaded dynamically onto window
78
- if (containerEl && window.Plotly) {
79
- // @ts-expect-error — Plotly is loaded dynamically onto window
80
- window.Plotly.Plots.resize(containerEl);
81
- }
82
- });
90
+ // `tick()` waits for the h-dvh/h-95 class swap this state drives to actually reach the
91
+ // DOM before resizeChart measures it.
92
+ tick().then(resizeChart);
83
93
  }
84
94
  document.addEventListener('fullscreenchange', onFullscreenChange);
85
95
  return () => document.removeEventListener('fullscreenchange', onFullscreenChange);
86
96
  });
87
97
 
98
+ // Chromium's getComputedStyle returns color values in whatever function the CSS used to
99
+ // declare them — oklch() here, since base.css's theme tokens are oklch(). Plotly's own color
100
+ // parser doesn't recognize oklch() and silently falls back to its #444 default instead of
101
+ // erroring, so every chart read dark axis text even in dark mode. `fillStyle` alone just
102
+ // echoes the oklch() string back unconverted; actually rasterizing a pixel and reading its
103
+ // bytes is what forces a real rgb() value Plotly can parse.
104
+ function toRgb(cssColor: string): string {
105
+ const canvas = document.createElement('canvas');
106
+ canvas.width = 1;
107
+ canvas.height = 1;
108
+ const ctx = canvas.getContext('2d');
109
+ if (!ctx) return cssColor;
110
+ ctx.fillStyle = cssColor;
111
+ ctx.fillRect(0, 0, 1, 1);
112
+ const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
113
+ return a === 255 ? `rgb(${r}, ${g}, ${b})` : `rgba(${r}, ${g}, ${b}, ${(a / 255).toFixed(3)})`;
114
+ }
115
+
88
116
  function getThemeColors(): { textColor: string; gridColor: string } {
89
117
  if (!containerEl) return { textColor: '#333', gridColor: '#eee' };
90
118
  const style = getComputedStyle(containerEl);
91
- const textColor = style.getPropertyValue('color').trim() || '#333';
92
- const gridColor =
119
+ const textColor = toRgb(style.getPropertyValue('color').trim() || '#333');
120
+ const gridColor = toRgb(
93
121
  style.getPropertyValue('--border')?.trim() ||
94
- style.getPropertyValue('--color-border')?.trim() ||
95
- 'rgba(128,128,128,0.2)';
122
+ style.getPropertyValue('--color-border')?.trim() ||
123
+ 'rgba(128,128,128,0.2)'
124
+ );
96
125
  return { textColor, gridColor };
97
126
  }
98
127
 
@@ -125,11 +154,15 @@
125
154
  font: { color: textColor },
126
155
  paper_bgcolor: layoutRest.paper_bgcolor ?? 'transparent',
127
156
  plot_bgcolor: layoutRest.plot_bgcolor ?? 'transparent',
128
- ...(!isMapFigure && layoutRest.xaxis
129
- ? { xaxis: { ...(layoutRest.xaxis as object), gridcolor: gridColor, color: textColor } }
130
- : {}),
131
- ...(!isMapFigure && layoutRest.yaxis
132
- ? { yaxis: { ...(layoutRest.yaxis as object), gridcolor: gridColor, color: textColor } }
157
+ // Plotly's axis tick/label color doesn't inherit the top-level `font.color`, so this
158
+ // has to be set even when the figure's own JSON never declared an xaxis/yaxis —
159
+ // which the mock's figures don't. Gating on `layoutRest.xaxis` existing left every
160
+ // axis on Plotly's built-in #444 default, light or dark mode.
161
+ ...(!isMapFigure
162
+ ? {
163
+ xaxis: { ...(layoutRest.xaxis as object), gridcolor: gridColor, color: textColor },
164
+ yaxis: { ...(layoutRest.yaxis as object), gridcolor: gridColor, color: textColor }
165
+ }
133
166
  : {})
134
167
  };
135
168
 
@@ -154,11 +187,7 @@
154
187
  const ro = new ResizeObserver(() => {
155
188
  if (resizeTimer) clearTimeout(resizeTimer);
156
189
  resizeTimer = setTimeout(() => {
157
- // @ts-expect-error — Plotly is loaded dynamically onto window
158
- if (containerEl && window.Plotly) {
159
- // @ts-expect-error — Plotly is loaded dynamically onto window
160
- window.Plotly.Plots.resize(containerEl);
161
- }
190
+ resizeChart();
162
191
  resizeTimer = null;
163
192
  }, 150);
164
193
  });
@@ -175,6 +204,10 @@
175
204
  let renderTimeout: ReturnType<typeof setTimeout> | null = null;
176
205
 
177
206
  $effect(() => {
207
+ // Re-reads mode.current so a theme toggle re-renders with the new axis/font colors —
208
+ // getThemeColors() only reads the DOM once, at render time, and nothing else repaints an
209
+ // already-drawn chart when the page's color scheme changes under it.
210
+ void mode.current;
178
211
  if (!value || !mounted || !containerEl) return;
179
212
  if (renderTimeout) clearTimeout(renderTimeout);
180
213
  renderTimeout = setTimeout(() => {
@@ -189,7 +222,7 @@
189
222
 
190
223
  <div
191
224
  bind:this={wrapperEl}
192
- class="group rounded relative overflow-hidden border border-border {isFullscreen
225
+ class="group relative overflow-hidden rounded-lg border border-border {isFullscreen
193
226
  ? 'bg-background'
194
227
  : ''}"
195
228
  >
@@ -65,7 +65,8 @@
65
65
  // Some embedded WebViews (WKWebView in the Rhino panel) ship without the Fullscreen API at all.
66
66
  const fullscreenSupported = typeof document !== 'undefined' && !!document.fullscreenEnabled;
67
67
  const allowFullscreen = $derived((cfg.allowFullscreen ?? true) && fullscreenSupported);
68
- const backgroundColor = $derived(cfg.backgroundColor);
68
+ // SVGs are line drawings (often black on transparent), so a checkerboard hurts legibility.
69
+ const backgroundColor = $derived(cfg.backgroundColor ?? (isSvg ? '#ffffff' : undefined));
69
70
 
70
71
  let wrapperEl = $state<HTMLDivElement | null>(null);
71
72
  let stageEl = $state<HTMLDivElement | null>(null);
@@ -303,7 +304,7 @@
303
304
  {#if error || unsupported || current}
304
305
  <div
305
306
  bind:this={wrapperEl}
306
- class="rounded flex flex-col overflow-hidden border border-border bg-background {isFullscreen
307
+ class="flex flex-col overflow-hidden rounded-lg border border-border bg-background {isFullscreen
307
308
  ? 'h-dvh'
308
309
  : ''}"
309
310
  >
@@ -319,6 +320,7 @@
319
320
  {:else if current}
320
321
  <!-- The stage is a pan/zoom surface with its own keyboard shortcuts; no native element fits. -->
321
322
  <!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
323
+ <!-- eslint-disable shadcn/no-inline-styles -- backgroundColor is the author's per-output color, not a theme token -->
322
324
  <div
323
325
  bind:this={stageEl}
324
326
  role="application"
@@ -338,6 +340,7 @@
338
340
  ondblclick={onDoubleClick}
339
341
  onkeydown={onKeyDown}
340
342
  >
343
+ <!-- eslint-enable shadcn/no-inline-styles -->
341
344
  {#if decodeFailed}
342
345
  <div class="inset-0 text-sm absolute flex items-center justify-center text-destructive">
343
346
  {formatMessage(t.imageDecodeFailed, { file: `${current.fileName}${current.fileType}` })}
@@ -38,7 +38,7 @@
38
38
  );
39
39
  const hasValue = $derived(value != null);
40
40
 
41
- const boxClass = 'min-h-12.5 rounded px-3 py-3 font-mono text-sm border border-border';
41
+ const boxClass = 'min-h-12.5 rounded-md px-3 py-3 text-sm border border-border/60';
42
42
 
43
43
  async function copyToClipboard() {
44
44
  try {
@@ -157,7 +157,11 @@
157
157
 
158
158
  {#snippet fieldHeader()}
159
159
  <div class="gap-2 flex items-center">
160
- <Label>{label}</Label>
160
+ <Label>
161
+ <span class={item.widgetType === 'number' ? 'text-xs font-normal text-muted-foreground' : ''}
162
+ >{label}</span
163
+ >
164
+ </Label>
161
165
  {#if item.description}
162
166
  <HelpButton {label} description={item.description} />
163
167
  {/if}
@@ -213,12 +217,12 @@
213
217
 
214
218
  {#snippet fileDisplay()}
215
219
  {#if fileCount === 0}
216
- <div class="rounded px-3 py-3 text-sm border border-border bg-muted text-muted-foreground">
220
+ <div class="px-3 py-3 text-sm rounded-md border border-border bg-muted text-muted-foreground">
217
221
  {t.waitingForFiles}
218
222
  </div>
219
223
  {:else}
220
224
  <div class="gap-2 flex flex-col">
221
- <div class="rounded px-3 py-2 border border-border bg-muted">
225
+ <div class="px-3 py-2 rounded-md border border-border bg-muted">
222
226
  <div class="gap-2 flex items-center justify-between">
223
227
  <span class="min-w-0 text-sm font-medium truncate text-foreground">
224
228
  {fileCount === 1
@@ -248,7 +252,7 @@
248
252
  </div>
249
253
  {#if hasDuplicates}
250
254
  <div
251
- class="rounded px-3 py-2 text-sm border border-destructive bg-destructive/10 text-destructive"
255
+ class="px-3 py-2 text-sm rounded-md border border-destructive bg-destructive/10 text-destructive"
252
256
  >
253
257
  <div class="font-medium">{t.duplicateFilesTitle}</div>
254
258
  <div class="mt-1 text-xs">
@@ -264,7 +268,7 @@
264
268
  {/if}
265
269
  {#if downloadError}
266
270
  <div
267
- class="rounded px-3 py-2 text-sm border border-destructive bg-destructive/10 text-destructive"
271
+ class="px-3 py-2 text-sm rounded-md border border-destructive bg-destructive/10 text-destructive"
268
272
  >
269
273
  {downloadError}
270
274
  </div>
@@ -282,7 +286,10 @@
282
286
 
283
287
  <!-- Dynamic value list outputs are routing sinks (their options feed an input), not displayed. -->
284
288
  {#if item.widgetType !== 'dynamicValueList'}
285
- <div role="group" class="group/field gap-2 flex flex-col">
289
+ <div
290
+ role="group"
291
+ class="group/field flex flex-col {item.widgetType === 'number' ? 'gap-0.5' : 'gap-2'}"
292
+ >
286
293
  {@render fieldHeader()}
287
294
 
288
295
  {#if item.widgetType === 'chart'}
@@ -295,18 +302,18 @@
295
302
  {:else if item.widgetType === 'file'}
296
303
  {@render fileDisplay()}
297
304
  {:else if item.widgetType === 'number'}
298
- <div class="{boxClass} flex items-center bg-muted/50 wrap-break-word">
305
+ <div class="min-h-8 text-2xl font-semibold wrap-break-word text-foreground tabular-nums">
299
306
  {#if hasValue}
300
- <span class="font-bold text-primary">{formattedValue}</span>
307
+ {formattedValue}
301
308
  {:else}
302
- {@render placeholder()}
309
+ <span class="text-base font-normal">{@render placeholder()}</span>
303
310
  {/if}
304
311
  </div>
305
312
  {:else if item.widgetType === 'text'}
306
313
  <div class="group relative">
307
314
  {#if isObjectValue}
308
315
  <pre
309
- class="{boxClass} max-h-96 overflow-auto bg-muted/10 wrap-anywhere text-foreground">{formattedValue}</pre>
316
+ class="{boxClass} max-h-96 font-mono overflow-auto bg-muted/10 wrap-anywhere text-foreground">{formattedValue}</pre>
310
317
  {:else}
311
318
  <div
312
319
  class="{boxClass} bg-muted/10 wrap-anywhere wrap-break-word whitespace-pre-wrap text-foreground"
@@ -104,7 +104,7 @@
104
104
  {#if bare}
105
105
  {@render tabs()}
106
106
  {:else}
107
- <Card.Root variant="flush" class="min-h-0 mx-1 flex-1">
107
+ <Card.Root variant="flush" class="min-h-0 flex-1">
108
108
  {@render tabs()}
109
109
  </Card.Root>
110
110
  {/if}
@@ -33,9 +33,11 @@
33
33
  * repaint, so the row updates instantly and the geometry lags behind.
34
34
  */
35
35
  onVisibilityChange?: () => void;
36
+ /** The entry under the pointer, or null once it leaves. Drives the viewport preview tint. */
37
+ onHover?: (entry: SceneEntry | null) => void;
36
38
  }
37
39
 
38
- let { outliner, sceneVersion = 0, onVisibilityChange }: Props = $props();
40
+ let { outliner, sceneVersion = 0, onVisibilityChange, onHover }: Props = $props();
39
41
 
40
42
  // A partially hidden layer hides the rest; only a fully hidden one comes back — the same rule
41
43
  // `visibility.toggleLayer` applies to objects, restated over entries because a layer's rows are
@@ -51,6 +53,13 @@
51
53
  onVisibilityChange?.();
52
54
  };
53
55
 
56
+ // A solve or closing the panel removes the row without a mouseleave, which would leave the
57
+ // preview tint stuck on the viewport.
58
+ $effect(() => {
59
+ void sceneVersion;
60
+ return () => onHover?.(null);
61
+ });
62
+
54
63
  // Derived, not destructured: the prop is reassignable, and these must follow it.
55
64
  const hidden = $derived(outliner.visibility.hidden);
56
65
  const selected = $derived(outliner.selection.selected);
@@ -331,6 +340,8 @@
331
340
  class="gap-1.5 pl-5 pr-2 flex h-full cursor-pointer items-center transition-colors
332
341
  {isSelected ? 'bg-primary/10 hover:bg-primary/15' : 'hover:bg-muted'}
333
342
  {isHidden ? 'opacity-40' : ''}"
343
+ onmouseenter={() => onHover?.(entry)}
344
+ onmouseleave={() => onHover?.(null)}
334
345
  onmousedown={(e) => {
335
346
  e.stopPropagation();
336
347
  if (e.shiftKey) e.preventDefault();
@@ -1,4 +1,4 @@
1
- import { type SceneOutliner } from '@selvajs/visualization/scene';
1
+ import { type SceneEntry, type SceneOutliner } from '@selvajs/visualization/scene';
2
2
  interface Props {
3
3
  /**
4
4
  * Owned by `Viewer.svelte`, never built here: this panel unmounts when it closes, and hidden
@@ -13,6 +13,8 @@ interface Props {
13
13
  * repaint, so the row updates instantly and the geometry lags behind.
14
14
  */
15
15
  onVisibilityChange?: () => void;
16
+ /** The entry under the pointer, or null once it leaves. Drives the viewport preview tint. */
17
+ onHover?: (entry: SceneEntry | null) => void;
16
18
  }
17
19
  declare const SceneManager: import("svelte").Component<Props, {}, "">;
18
20
  type SceneManager = ReturnType<typeof SceneManager>;
@@ -129,6 +129,8 @@
129
129
  let updateShadowBounds: (() => void) | null = null;
130
130
  let fitToView: (() => void) | null = null;
131
131
  let clearSelection: (() => void) | null = null;
132
+ let selectEntries: ThreeViewer['selectEntries'] | null = null;
133
+ let setHover: ThreeViewer['setHover'] | null = null;
132
134
  let viewerInitialized = false;
133
135
  let sceneVersion = $state(0);
134
136
  let hideButton = $state(false);
@@ -203,8 +205,8 @@
203
205
  events: {
204
206
  // Fires before onMeshMetadataClicked, so picking an object without metadata closes the
205
207
  // previous object's panel instead of leaving it describing the wrong thing.
206
- onObjectSelected: closeMetadata,
207
- onBackgroundClicked: closeMetadata,
208
+ onObjectSelected: dropPanelSelection,
209
+ onBackgroundClicked: dropPanelSelection,
208
210
  onMeshMetadataClicked: config.enableMeshClick
209
211
  ? (metadata: Record<string, unknown>) => {
210
212
  if (hasUsefulMetadata(metadata)) {
@@ -236,6 +238,8 @@
236
238
  updateShadowBounds = init.updateShadowBounds;
237
239
  fitToView = init.fitToView;
238
240
  clearSelection = init.clearSelection;
241
+ selectEntries = init.selectEntries;
242
+ setHover = init.setHover;
239
243
  projection = init.cameraController.getProjection();
240
244
 
241
245
  // Untracked so the host reading `meshes` or config inside its setup can't re-run onMount's teardown.
@@ -389,6 +393,24 @@
389
393
  selectedMeshName = null;
390
394
  }
391
395
 
396
+ // A click on the canvas replaces whatever the panel selected: one selection at a time.
397
+ function dropPanelSelection() {
398
+ closeMetadata();
399
+ selectedObjects.clear();
400
+ }
401
+
402
+ // Mirrors the panel's selection into the viewport. The size check skips walking the entries for
403
+ // the common empty case, which also keeps `has()` from subscribing to every key.
404
+ $effect(() => {
405
+ if (selectedObjects.size === 0) {
406
+ selectEntries?.([]);
407
+ return;
408
+ }
409
+ const chosen = outliner?.entries().filter((entry) => selectedObjects.has(entry.key)) ?? [];
410
+ closeMetadata();
411
+ selectEntries?.(chosen);
412
+ });
413
+
392
414
  function dismissMetadata() {
393
415
  closeMetadata();
394
416
  clearSelection?.();
@@ -655,7 +677,12 @@
655
677
  {#if sceneManagerOpen && scene && outliner}
656
678
  <Resizable.Handle withHandle />
657
679
  <Resizable.Pane id="scene-manager" order={2} defaultSize={15} minSize={8} maxSize={30}>
658
- <SceneManager {outliner} {sceneVersion} onVisibilityChange={() => invalidate?.()} />
680
+ <SceneManager
681
+ {outliner}
682
+ {sceneVersion}
683
+ onVisibilityChange={() => invalidate?.()}
684
+ onHover={(entry) => setHover?.(entry?.object ?? null, entry?.memberIndex ?? null)}
685
+ />
659
686
  </Resizable.Pane>
660
687
  {/if}
661
688
  </Resizable.PaneGroup>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selvajs/ui",
3
- "version": "6.6.0-beta.11",
3
+ "version": "6.6.0-beta.13",
4
4
  "description": "Shared UI components and utilities for Selva applications",
5
5
  "license": "MIT",
6
6
  "author": "VektorNode",
@@ -31,12 +31,16 @@
31
31
  ".": {
32
32
  "selva-source": {
33
33
  "types": "./dist/index.d.ts",
34
- "svelte": "./dist/index.js"
34
+ "svelte": "./src/lib/index.ts"
35
35
  },
36
36
  "types": "./dist/public.d.ts",
37
37
  "svelte": "./dist/public.js"
38
38
  },
39
39
  "./external": {
40
+ "selva-source": {
41
+ "types": "./dist/external/storage.d.ts",
42
+ "svelte": "./src/lib/external/storage.ts"
43
+ },
40
44
  "types": "./dist/external/storage.d.ts",
41
45
  "svelte": "./dist/external/storage.js"
42
46
  },
@@ -61,8 +65,8 @@
61
65
  "three": "^0.186.1",
62
66
  "@selvajs/compute": "^5.0.0-beta.8",
63
67
  "@selvajs/schemas": "^5.3.0-beta.1",
64
- "@selvajs/solve": "^2.0.0-beta.10",
65
- "@selvajs/visualization": "^1.6.0-beta.5"
68
+ "@selvajs/visualization": "^1.6.0-beta.7",
69
+ "@selvajs/solve": "^2.0.0-beta.10"
66
70
  },
67
71
  "peerDependenciesMeta": {
68
72
  "three": {
@@ -92,8 +96,8 @@
92
96
  "vitest": "^5.0.5",
93
97
  "@selvajs/compute": "5.0.0-beta.8",
94
98
  "@selvajs/config": "0.0.4",
95
- "@selvajs/visualization": "1.6.0-beta.5",
96
99
  "@selvajs/schemas": "5.3.0-beta.1",
100
+ "@selvajs/visualization": "1.6.0-beta.7",
97
101
  "@selvajs/solve": "2.0.0-beta.10"
98
102
  },
99
103
  "scripts": {
@@ -420,7 +420,7 @@
420
420
  {/if}
421
421
  {:else if !hasViewer && !isTwoPanelMode && hasLeftPanel !== hasRightPanel}
422
422
  <!-- Single centered panel (left-only or right-only), no viewer -->
423
- <div class="min-h-0 flex h-full flex-1 justify-center">
423
+ <div class="min-h-0 p-1 flex h-full flex-1 justify-center">
424
424
  <div class="min-h-0 flex h-full w-4/5 flex-col">
425
425
  {@render panelContent(
426
426
  hasRightPanel ? 'right' : undefined,
@@ -447,7 +447,7 @@
447
447
  <Resizable.PaneGroup
448
448
  direction="horizontal"
449
449
  autoSaveId="selva-layout-{layoutKey}"
450
- class="min-h-0 flex-1"
450
+ class="min-h-0 m-1 flex-1"
451
451
  >
452
452
  {#if hasLeftPanel}
453
453
  <Resizable.Pane
@@ -460,7 +460,6 @@
460
460
  collapsedSize={0}
461
461
  onCollapse={() => (leftCollapsed = true)}
462
462
  onExpand={() => (leftCollapsed = false)}
463
- class="ml-1"
464
463
  >
465
464
  <div
466
465
  class="min-h-0 flex h-full flex-col {isViewerFullscreen || leftCollapsed
@@ -481,7 +480,7 @@
481
480
  {/if}
482
481
 
483
482
  {#if hasViewer}
484
- <Resizable.Pane order={2} minSize={20} class="min-h-0 mx-1 flex flex-col">
483
+ <Resizable.Pane order={2} minSize={20} class="min-h-0 flex flex-col">
485
484
  <Viewer
486
485
  {meshes}
487
486
  bind:isFullscreen={isViewerFullscreen}
@@ -498,14 +497,16 @@
498
497
  {/if}
499
498
 
500
499
  {#if hasRightPanel}
501
- <Resizable.Handle
502
- withHandle
503
- line={false}
504
- data-collapsed={rightCollapsed}
505
- onclick={() => {
506
- if (rightCollapsed) rightPaneRef?.expand();
507
- }}
508
- />
500
+ {#if hasViewer || !hasLeftPanel}
501
+ <Resizable.Handle
502
+ withHandle
503
+ line={false}
504
+ data-collapsed={rightCollapsed}
505
+ onclick={() => {
506
+ if (rightCollapsed) rightPaneRef?.expand();
507
+ }}
508
+ />
509
+ {/if}
509
510
  <Resizable.Pane
510
511
  bind:this={rightPaneRef}
511
512
  order={3}
@@ -516,7 +517,6 @@
516
517
  collapsedSize={0}
517
518
  onCollapse={() => (rightCollapsed = true)}
518
519
  onExpand={() => (rightCollapsed = false)}
519
- class="mr-1"
520
520
  >
521
521
  <div
522
522
  class="min-h-0 flex h-full flex-col {isViewerFullscreen || rightCollapsed
@@ -20,12 +20,14 @@
20
20
  [
21
21
  'gap-1 py-2 lg:py-3 lg:flex-col lg:w-auto px-2 lg:px-0',
22
22
  'flex w-full shrink-0 flex-row items-center',
23
- 'backdrop-blur-sm bg-background/90 cursor-pointer'
23
+ 'backdrop-blur-sm bg-background/90 cursor-pointer',
24
+ side === 'left' ? 'lg:border-r' : 'lg:border-l',
25
+ 'border-border'
24
26
  ].join(' ')
25
27
  );
26
28
 
27
29
  const tabButtonClass =
28
- 'w-8 h-8 rounded cursor-pointer flex shrink-0 items-center justify-center text-muted-foreground transition-colors hover:bg-accent hover:text-foreground';
30
+ 'w-8 h-8 rounded-md cursor-pointer flex shrink-0 items-center justify-center text-muted-foreground transition-colors hover:bg-accent hover:text-foreground';
29
31
  </script>
30
32
 
31
33
  <div
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { OutputChartLayoutItem } from '@selvajs/schemas';
3
- import { onMount } from 'svelte';
3
+ import { onMount, tick } from 'svelte';
4
+ import { mode } from 'mode-watcher';
4
5
  import { Loader, Maximize, Minimize } from '@lucide/svelte';
5
6
  import { loadScript } from '$lib/utils/loadScript';
6
7
  import { getLocaleContext } from '$lib/i18n/localeContext.svelte';
@@ -57,6 +58,19 @@
57
58
  // Some embedded WebViews (WKWebView in the Rhino panel) ship without the Fullscreen API at all.
58
59
  const fullscreenSupported = typeof document !== 'undefined' && !!document.fullscreenEnabled;
59
60
 
61
+ // `Plots.resize()`/`relayout({autosize: true})` ask Plotly to re-measure the container itself,
62
+ // which it silently fails to do across a Fullscreen transition — _fullLayout keeps the
63
+ // pre-toggle width/height even though clientWidth/clientHeight are already correct. Passing
64
+ // the measured size explicitly is the only call that actually takes.
65
+ function resizeChart() {
66
+ // @ts-expect-error — Plotly is loaded dynamically onto window
67
+ if (!containerEl || !window.Plotly) return;
68
+ const { width, height } = containerEl.getBoundingClientRect();
69
+ if (width === 0 || height === 0) return;
70
+ // @ts-expect-error — Plotly is loaded dynamically onto window
71
+ window.Plotly.relayout(containerEl, { width, height });
72
+ }
73
+
60
74
  function toggleFullscreen() {
61
75
  if (!wrapperEl) return;
62
76
  if (!document.fullscreenElement) {
@@ -73,26 +87,41 @@
73
87
  $effect(() => {
74
88
  function onFullscreenChange() {
75
89
  isFullscreen = !!document.fullscreenElement;
76
- Promise.resolve().then(() => {
77
- // @ts-expect-error — Plotly is loaded dynamically onto window
78
- if (containerEl && window.Plotly) {
79
- // @ts-expect-error — Plotly is loaded dynamically onto window
80
- window.Plotly.Plots.resize(containerEl);
81
- }
82
- });
90
+ // `tick()` waits for the h-dvh/h-95 class swap this state drives to actually reach the
91
+ // DOM before resizeChart measures it.
92
+ tick().then(resizeChart);
83
93
  }
84
94
  document.addEventListener('fullscreenchange', onFullscreenChange);
85
95
  return () => document.removeEventListener('fullscreenchange', onFullscreenChange);
86
96
  });
87
97
 
98
+ // Chromium's getComputedStyle returns color values in whatever function the CSS used to
99
+ // declare them — oklch() here, since base.css's theme tokens are oklch(). Plotly's own color
100
+ // parser doesn't recognize oklch() and silently falls back to its #444 default instead of
101
+ // erroring, so every chart read dark axis text even in dark mode. `fillStyle` alone just
102
+ // echoes the oklch() string back unconverted; actually rasterizing a pixel and reading its
103
+ // bytes is what forces a real rgb() value Plotly can parse.
104
+ function toRgb(cssColor: string): string {
105
+ const canvas = document.createElement('canvas');
106
+ canvas.width = 1;
107
+ canvas.height = 1;
108
+ const ctx = canvas.getContext('2d');
109
+ if (!ctx) return cssColor;
110
+ ctx.fillStyle = cssColor;
111
+ ctx.fillRect(0, 0, 1, 1);
112
+ const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
113
+ return a === 255 ? `rgb(${r}, ${g}, ${b})` : `rgba(${r}, ${g}, ${b}, ${(a / 255).toFixed(3)})`;
114
+ }
115
+
88
116
  function getThemeColors(): { textColor: string; gridColor: string } {
89
117
  if (!containerEl) return { textColor: '#333', gridColor: '#eee' };
90
118
  const style = getComputedStyle(containerEl);
91
- const textColor = style.getPropertyValue('color').trim() || '#333';
92
- const gridColor =
119
+ const textColor = toRgb(style.getPropertyValue('color').trim() || '#333');
120
+ const gridColor = toRgb(
93
121
  style.getPropertyValue('--border')?.trim() ||
94
- style.getPropertyValue('--color-border')?.trim() ||
95
- 'rgba(128,128,128,0.2)';
122
+ style.getPropertyValue('--color-border')?.trim() ||
123
+ 'rgba(128,128,128,0.2)'
124
+ );
96
125
  return { textColor, gridColor };
97
126
  }
98
127
 
@@ -125,11 +154,15 @@
125
154
  font: { color: textColor },
126
155
  paper_bgcolor: layoutRest.paper_bgcolor ?? 'transparent',
127
156
  plot_bgcolor: layoutRest.plot_bgcolor ?? 'transparent',
128
- ...(!isMapFigure && layoutRest.xaxis
129
- ? { xaxis: { ...(layoutRest.xaxis as object), gridcolor: gridColor, color: textColor } }
130
- : {}),
131
- ...(!isMapFigure && layoutRest.yaxis
132
- ? { yaxis: { ...(layoutRest.yaxis as object), gridcolor: gridColor, color: textColor } }
157
+ // Plotly's axis tick/label color doesn't inherit the top-level `font.color`, so this
158
+ // has to be set even when the figure's own JSON never declared an xaxis/yaxis —
159
+ // which the mock's figures don't. Gating on `layoutRest.xaxis` existing left every
160
+ // axis on Plotly's built-in #444 default, light or dark mode.
161
+ ...(!isMapFigure
162
+ ? {
163
+ xaxis: { ...(layoutRest.xaxis as object), gridcolor: gridColor, color: textColor },
164
+ yaxis: { ...(layoutRest.yaxis as object), gridcolor: gridColor, color: textColor }
165
+ }
133
166
  : {})
134
167
  };
135
168
 
@@ -154,11 +187,7 @@
154
187
  const ro = new ResizeObserver(() => {
155
188
  if (resizeTimer) clearTimeout(resizeTimer);
156
189
  resizeTimer = setTimeout(() => {
157
- // @ts-expect-error — Plotly is loaded dynamically onto window
158
- if (containerEl && window.Plotly) {
159
- // @ts-expect-error — Plotly is loaded dynamically onto window
160
- window.Plotly.Plots.resize(containerEl);
161
- }
190
+ resizeChart();
162
191
  resizeTimer = null;
163
192
  }, 150);
164
193
  });
@@ -175,6 +204,10 @@
175
204
  let renderTimeout: ReturnType<typeof setTimeout> | null = null;
176
205
 
177
206
  $effect(() => {
207
+ // Re-reads mode.current so a theme toggle re-renders with the new axis/font colors —
208
+ // getThemeColors() only reads the DOM once, at render time, and nothing else repaints an
209
+ // already-drawn chart when the page's color scheme changes under it.
210
+ void mode.current;
178
211
  if (!value || !mounted || !containerEl) return;
179
212
  if (renderTimeout) clearTimeout(renderTimeout);
180
213
  renderTimeout = setTimeout(() => {
@@ -189,7 +222,7 @@
189
222
 
190
223
  <div
191
224
  bind:this={wrapperEl}
192
- class="group rounded relative overflow-hidden border border-border {isFullscreen
225
+ class="group relative overflow-hidden rounded-lg border border-border {isFullscreen
193
226
  ? 'bg-background'
194
227
  : ''}"
195
228
  >
@@ -65,7 +65,8 @@
65
65
  // Some embedded WebViews (WKWebView in the Rhino panel) ship without the Fullscreen API at all.
66
66
  const fullscreenSupported = typeof document !== 'undefined' && !!document.fullscreenEnabled;
67
67
  const allowFullscreen = $derived((cfg.allowFullscreen ?? true) && fullscreenSupported);
68
- const backgroundColor = $derived(cfg.backgroundColor);
68
+ // SVGs are line drawings (often black on transparent), so a checkerboard hurts legibility.
69
+ const backgroundColor = $derived(cfg.backgroundColor ?? (isSvg ? '#ffffff' : undefined));
69
70
 
70
71
  let wrapperEl = $state<HTMLDivElement | null>(null);
71
72
  let stageEl = $state<HTMLDivElement | null>(null);
@@ -303,7 +304,7 @@
303
304
  {#if error || unsupported || current}
304
305
  <div
305
306
  bind:this={wrapperEl}
306
- class="rounded flex flex-col overflow-hidden border border-border bg-background {isFullscreen
307
+ class="flex flex-col overflow-hidden rounded-lg border border-border bg-background {isFullscreen
307
308
  ? 'h-dvh'
308
309
  : ''}"
309
310
  >
@@ -319,6 +320,7 @@
319
320
  {:else if current}
320
321
  <!-- The stage is a pan/zoom surface with its own keyboard shortcuts; no native element fits. -->
321
322
  <!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
323
+ <!-- eslint-disable shadcn/no-inline-styles -- backgroundColor is the author's per-output color, not a theme token -->
322
324
  <div
323
325
  bind:this={stageEl}
324
326
  role="application"
@@ -338,6 +340,7 @@
338
340
  ondblclick={onDoubleClick}
339
341
  onkeydown={onKeyDown}
340
342
  >
343
+ <!-- eslint-enable shadcn/no-inline-styles -->
341
344
  {#if decodeFailed}
342
345
  <div class="inset-0 text-sm absolute flex items-center justify-center text-destructive">
343
346
  {formatMessage(t.imageDecodeFailed, { file: `${current.fileName}${current.fileType}` })}
@@ -38,7 +38,7 @@
38
38
  );
39
39
  const hasValue = $derived(value != null);
40
40
 
41
- const boxClass = 'min-h-12.5 rounded px-3 py-3 font-mono text-sm border border-border';
41
+ const boxClass = 'min-h-12.5 rounded-md px-3 py-3 text-sm border border-border/60';
42
42
 
43
43
  async function copyToClipboard() {
44
44
  try {
@@ -157,7 +157,11 @@
157
157
 
158
158
  {#snippet fieldHeader()}
159
159
  <div class="gap-2 flex items-center">
160
- <Label>{label}</Label>
160
+ <Label>
161
+ <span class={item.widgetType === 'number' ? 'text-xs font-normal text-muted-foreground' : ''}
162
+ >{label}</span
163
+ >
164
+ </Label>
161
165
  {#if item.description}
162
166
  <HelpButton {label} description={item.description} />
163
167
  {/if}
@@ -213,12 +217,12 @@
213
217
 
214
218
  {#snippet fileDisplay()}
215
219
  {#if fileCount === 0}
216
- <div class="rounded px-3 py-3 text-sm border border-border bg-muted text-muted-foreground">
220
+ <div class="px-3 py-3 text-sm rounded-md border border-border bg-muted text-muted-foreground">
217
221
  {t.waitingForFiles}
218
222
  </div>
219
223
  {:else}
220
224
  <div class="gap-2 flex flex-col">
221
- <div class="rounded px-3 py-2 border border-border bg-muted">
225
+ <div class="px-3 py-2 rounded-md border border-border bg-muted">
222
226
  <div class="gap-2 flex items-center justify-between">
223
227
  <span class="min-w-0 text-sm font-medium truncate text-foreground">
224
228
  {fileCount === 1
@@ -248,7 +252,7 @@
248
252
  </div>
249
253
  {#if hasDuplicates}
250
254
  <div
251
- class="rounded px-3 py-2 text-sm border border-destructive bg-destructive/10 text-destructive"
255
+ class="px-3 py-2 text-sm rounded-md border border-destructive bg-destructive/10 text-destructive"
252
256
  >
253
257
  <div class="font-medium">{t.duplicateFilesTitle}</div>
254
258
  <div class="mt-1 text-xs">
@@ -264,7 +268,7 @@
264
268
  {/if}
265
269
  {#if downloadError}
266
270
  <div
267
- class="rounded px-3 py-2 text-sm border border-destructive bg-destructive/10 text-destructive"
271
+ class="px-3 py-2 text-sm rounded-md border border-destructive bg-destructive/10 text-destructive"
268
272
  >
269
273
  {downloadError}
270
274
  </div>
@@ -282,7 +286,10 @@
282
286
 
283
287
  <!-- Dynamic value list outputs are routing sinks (their options feed an input), not displayed. -->
284
288
  {#if item.widgetType !== 'dynamicValueList'}
285
- <div role="group" class="group/field gap-2 flex flex-col">
289
+ <div
290
+ role="group"
291
+ class="group/field flex flex-col {item.widgetType === 'number' ? 'gap-0.5' : 'gap-2'}"
292
+ >
286
293
  {@render fieldHeader()}
287
294
 
288
295
  {#if item.widgetType === 'chart'}
@@ -295,18 +302,18 @@
295
302
  {:else if item.widgetType === 'file'}
296
303
  {@render fileDisplay()}
297
304
  {:else if item.widgetType === 'number'}
298
- <div class="{boxClass} flex items-center bg-muted/50 wrap-break-word">
305
+ <div class="min-h-8 text-2xl font-semibold wrap-break-word text-foreground tabular-nums">
299
306
  {#if hasValue}
300
- <span class="font-bold text-primary">{formattedValue}</span>
307
+ {formattedValue}
301
308
  {:else}
302
- {@render placeholder()}
309
+ <span class="text-base font-normal">{@render placeholder()}</span>
303
310
  {/if}
304
311
  </div>
305
312
  {:else if item.widgetType === 'text'}
306
313
  <div class="group relative">
307
314
  {#if isObjectValue}
308
315
  <pre
309
- class="{boxClass} max-h-96 overflow-auto bg-muted/10 wrap-anywhere text-foreground">{formattedValue}</pre>
316
+ class="{boxClass} max-h-96 font-mono overflow-auto bg-muted/10 wrap-anywhere text-foreground">{formattedValue}</pre>
310
317
  {:else}
311
318
  <div
312
319
  class="{boxClass} bg-muted/10 wrap-anywhere wrap-break-word whitespace-pre-wrap text-foreground"
@@ -104,7 +104,7 @@
104
104
  {#if bare}
105
105
  {@render tabs()}
106
106
  {:else}
107
- <Card.Root variant="flush" class="min-h-0 mx-1 flex-1">
107
+ <Card.Root variant="flush" class="min-h-0 flex-1">
108
108
  {@render tabs()}
109
109
  </Card.Root>
110
110
  {/if}
@@ -33,9 +33,11 @@
33
33
  * repaint, so the row updates instantly and the geometry lags behind.
34
34
  */
35
35
  onVisibilityChange?: () => void;
36
+ /** The entry under the pointer, or null once it leaves. Drives the viewport preview tint. */
37
+ onHover?: (entry: SceneEntry | null) => void;
36
38
  }
37
39
 
38
- let { outliner, sceneVersion = 0, onVisibilityChange }: Props = $props();
40
+ let { outliner, sceneVersion = 0, onVisibilityChange, onHover }: Props = $props();
39
41
 
40
42
  // A partially hidden layer hides the rest; only a fully hidden one comes back — the same rule
41
43
  // `visibility.toggleLayer` applies to objects, restated over entries because a layer's rows are
@@ -51,6 +53,13 @@
51
53
  onVisibilityChange?.();
52
54
  };
53
55
 
56
+ // A solve or closing the panel removes the row without a mouseleave, which would leave the
57
+ // preview tint stuck on the viewport.
58
+ $effect(() => {
59
+ void sceneVersion;
60
+ return () => onHover?.(null);
61
+ });
62
+
54
63
  // Derived, not destructured: the prop is reassignable, and these must follow it.
55
64
  const hidden = $derived(outliner.visibility.hidden);
56
65
  const selected = $derived(outliner.selection.selected);
@@ -331,6 +340,8 @@
331
340
  class="gap-1.5 pl-5 pr-2 flex h-full cursor-pointer items-center transition-colors
332
341
  {isSelected ? 'bg-primary/10 hover:bg-primary/15' : 'hover:bg-muted'}
333
342
  {isHidden ? 'opacity-40' : ''}"
343
+ onmouseenter={() => onHover?.(entry)}
344
+ onmouseleave={() => onHover?.(null)}
334
345
  onmousedown={(e) => {
335
346
  e.stopPropagation();
336
347
  if (e.shiftKey) e.preventDefault();
@@ -129,6 +129,8 @@
129
129
  let updateShadowBounds: (() => void) | null = null;
130
130
  let fitToView: (() => void) | null = null;
131
131
  let clearSelection: (() => void) | null = null;
132
+ let selectEntries: ThreeViewer['selectEntries'] | null = null;
133
+ let setHover: ThreeViewer['setHover'] | null = null;
132
134
  let viewerInitialized = false;
133
135
  let sceneVersion = $state(0);
134
136
  let hideButton = $state(false);
@@ -203,8 +205,8 @@
203
205
  events: {
204
206
  // Fires before onMeshMetadataClicked, so picking an object without metadata closes the
205
207
  // previous object's panel instead of leaving it describing the wrong thing.
206
- onObjectSelected: closeMetadata,
207
- onBackgroundClicked: closeMetadata,
208
+ onObjectSelected: dropPanelSelection,
209
+ onBackgroundClicked: dropPanelSelection,
208
210
  onMeshMetadataClicked: config.enableMeshClick
209
211
  ? (metadata: Record<string, unknown>) => {
210
212
  if (hasUsefulMetadata(metadata)) {
@@ -236,6 +238,8 @@
236
238
  updateShadowBounds = init.updateShadowBounds;
237
239
  fitToView = init.fitToView;
238
240
  clearSelection = init.clearSelection;
241
+ selectEntries = init.selectEntries;
242
+ setHover = init.setHover;
239
243
  projection = init.cameraController.getProjection();
240
244
 
241
245
  // Untracked so the host reading `meshes` or config inside its setup can't re-run onMount's teardown.
@@ -389,6 +393,24 @@
389
393
  selectedMeshName = null;
390
394
  }
391
395
 
396
+ // A click on the canvas replaces whatever the panel selected: one selection at a time.
397
+ function dropPanelSelection() {
398
+ closeMetadata();
399
+ selectedObjects.clear();
400
+ }
401
+
402
+ // Mirrors the panel's selection into the viewport. The size check skips walking the entries for
403
+ // the common empty case, which also keeps `has()` from subscribing to every key.
404
+ $effect(() => {
405
+ if (selectedObjects.size === 0) {
406
+ selectEntries?.([]);
407
+ return;
408
+ }
409
+ const chosen = outliner?.entries().filter((entry) => selectedObjects.has(entry.key)) ?? [];
410
+ closeMetadata();
411
+ selectEntries?.(chosen);
412
+ });
413
+
392
414
  function dismissMetadata() {
393
415
  closeMetadata();
394
416
  clearSelection?.();
@@ -655,7 +677,12 @@
655
677
  {#if sceneManagerOpen && scene && outliner}
656
678
  <Resizable.Handle withHandle />
657
679
  <Resizable.Pane id="scene-manager" order={2} defaultSize={15} minSize={8} maxSize={30}>
658
- <SceneManager {outliner} {sceneVersion} onVisibilityChange={() => invalidate?.()} />
680
+ <SceneManager
681
+ {outliner}
682
+ {sceneVersion}
683
+ onVisibilityChange={() => invalidate?.()}
684
+ onHover={(entry) => setHover?.(entry?.object ?? null, entry?.memberIndex ?? null)}
685
+ />
659
686
  </Resizable.Pane>
660
687
  {/if}
661
688
  </Resizable.PaneGroup>