@selvajs/ui 6.1.1 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/components/ConfirmDialog.svelte +59 -0
  2. package/dist/components/ConfirmDialog.svelte.d.ts +13 -0
  3. package/dist/components/compute/ComputeApp.svelte +10 -0
  4. package/dist/components/compute/ComputeApp.svelte.d.ts +3 -0
  5. package/dist/components/compute/ParameterPresetManager.svelte +3 -3
  6. package/dist/components/layout/AppShell.svelte +19 -2
  7. package/dist/components/layout/SideNav.svelte +16 -2
  8. package/dist/components/layout/SideNav.svelte.d.ts +5 -0
  9. package/dist/components/preview/inputs/FileInput.svelte +3 -2
  10. package/dist/components/primitives/Callout.svelte +73 -0
  11. package/dist/components/primitives/Callout.svelte.d.ts +40 -0
  12. package/dist/components/primitives/dialog/dialog-content.svelte +1 -1
  13. package/dist/components/primitives/index.d.ts +2 -0
  14. package/dist/components/primitives/index.js +2 -0
  15. package/dist/components/viewer/SceneManager.svelte +28 -12
  16. package/dist/components/viewer/SceneManager.svelte.d.ts +6 -0
  17. package/dist/components/viewer/Viewer.svelte +16 -18
  18. package/package.json +10 -9
  19. package/src/lib/components/ConfirmDialog.svelte +59 -0
  20. package/src/lib/components/compute/ComputeApp.svelte +10 -0
  21. package/src/lib/components/compute/ParameterPresetManager.svelte +3 -3
  22. package/src/lib/components/layout/AppShell.svelte +19 -2
  23. package/src/lib/components/layout/SideNav.svelte +16 -2
  24. package/src/lib/components/preview/inputs/FileInput.svelte +3 -2
  25. package/src/lib/components/primitives/Callout.svelte +73 -0
  26. package/src/lib/components/primitives/dialog/dialog-content.svelte +1 -1
  27. package/src/lib/components/primitives/index.ts +2 -0
  28. package/src/lib/components/viewer/SceneManager.svelte +28 -12
  29. package/src/lib/components/viewer/Viewer.svelte +16 -18
@@ -0,0 +1,59 @@
1
+ <script lang="ts">
2
+ import * as AlertDialog from './primitives/alert-dialog/index.js';
3
+
4
+ let {
5
+ open = $bindable(false),
6
+ title,
7
+ description,
8
+ confirmLabel = 'Continue',
9
+ pendingLabel,
10
+ cancelLabel = 'Cancel',
11
+ variant = 'default',
12
+ onConfirm
13
+ }: {
14
+ open?: boolean;
15
+ title: string;
16
+ description?: string;
17
+ confirmLabel?: string;
18
+ pendingLabel?: string;
19
+ cancelLabel?: string;
20
+ variant?: 'default' | 'destructive';
21
+ onConfirm: () => void | Promise<void>;
22
+ } = $props();
23
+
24
+ let pending = $state(false);
25
+
26
+ async function handleConfirm() {
27
+ pending = true;
28
+ try {
29
+ await onConfirm();
30
+ } finally {
31
+ pending = false;
32
+ }
33
+ }
34
+ </script>
35
+
36
+ <AlertDialog.Root bind:open onOpenChange={(next) => !pending && (open = next)}>
37
+ <AlertDialog.Content>
38
+ <AlertDialog.Header>
39
+ <AlertDialog.Title>{title}</AlertDialog.Title>
40
+ {#if description}
41
+ <AlertDialog.Description class={variant === 'destructive' ? 'text-destructive' : undefined}>
42
+ {description}
43
+ </AlertDialog.Description>
44
+ {/if}
45
+ </AlertDialog.Header>
46
+ <AlertDialog.Footer>
47
+ <AlertDialog.Cancel disabled={pending}>{cancelLabel}</AlertDialog.Cancel>
48
+ <AlertDialog.Action
49
+ onclick={handleConfirm}
50
+ disabled={pending}
51
+ class={variant === 'destructive'
52
+ ? 'text-destructive-foreground bg-destructive hover:bg-destructive/90'
53
+ : undefined}
54
+ >
55
+ {pending ? (pendingLabel ?? confirmLabel) : confirmLabel}
56
+ </AlertDialog.Action>
57
+ </AlertDialog.Footer>
58
+ </AlertDialog.Content>
59
+ </AlertDialog.Root>
@@ -0,0 +1,13 @@
1
+ type $$ComponentProps = {
2
+ open?: boolean;
3
+ title: string;
4
+ description?: string;
5
+ confirmLabel?: string;
6
+ pendingLabel?: string;
7
+ cancelLabel?: string;
8
+ variant?: 'default' | 'destructive';
9
+ onConfirm: () => void | Promise<void>;
10
+ };
11
+ declare const ConfirmDialog: import("svelte").Component<$$ComponentProps, {}, "open">;
12
+ type ConfirmDialog = ReturnType<typeof ConfirmDialog>;
13
+ export default ConfirmDialog;
@@ -72,6 +72,10 @@
72
72
  /** Viewer chrome and defaults. `backgroundColor` and `showSceneManager` are set by the layout. */
73
73
  viewerConfig?: ViewerConfig;
74
74
  headerRight?: Snippet;
75
+ // Primary nav rendered next to the brand, so the viewer keeps the app-wide nav.
76
+ navItems?: Snippet;
77
+ homeUrl?: string;
78
+ brandName?: string;
75
79
  // Replaces the built-in header; takes precedence over `headerRight`.
76
80
  header?: Snippet;
77
81
  // Scopes sessionStorage for external-input values; falls back to definitionKey then schema.id.
@@ -106,6 +110,9 @@
106
110
  footerItemId = 'footer-item',
107
111
  footerItemPriority = 0,
108
112
  headerRight,
113
+ navItems,
114
+ homeUrl,
115
+ brandName,
109
116
  header,
110
117
  onReady,
111
118
  onViewerReady,
@@ -215,6 +222,9 @@
215
222
  showFooter
216
223
  title={pageTitle}
217
224
  {showModeToggle}
225
+ {navItems}
226
+ {homeUrl}
227
+ {brandName}
218
228
  {copyrightName}
219
229
  {footerText}
220
230
  {header}
@@ -56,6 +56,9 @@ interface Props {
56
56
  /** Viewer chrome and defaults. `backgroundColor` and `showSceneManager` are set by the layout. */
57
57
  viewerConfig?: ViewerConfig;
58
58
  headerRight?: Snippet;
59
+ navItems?: Snippet;
60
+ homeUrl?: string;
61
+ brandName?: string;
59
62
  header?: Snippet;
60
63
  externalScopeKey?: string;
61
64
  clientSlot?: ClientSlot;
@@ -8,7 +8,7 @@
8
8
  exportStateAsJson,
9
9
  importStateFromJson
10
10
  } from '../../schema/param-exporter';
11
- import { Button, Input, Label, Textarea, Dialog, Card } from '../primitives';
11
+ import { Button, Input, Label, Textarea, Dialog, Card, toast } from '../primitives';
12
12
 
13
13
  import type { ActionButton } from '../../types/actionButton';
14
14
  import { DEFAULT_PRESET_LABELS, type PresetLabels } from '../../types/presetLabels';
@@ -68,7 +68,7 @@
68
68
 
69
69
  async function handleExport() {
70
70
  if (!exportName.trim()) {
71
- alert(t.saveNameRequired);
71
+ toast.error(t.saveNameRequired);
72
72
  return;
73
73
  }
74
74
 
@@ -108,7 +108,7 @@
108
108
  const imported = await importStateFromJson(input.files[0]);
109
109
  tryLoad(imported);
110
110
  } catch (error) {
111
- alert(t.loadImportError + (error as Error).message);
111
+ toast.error(t.loadImportError + (error as Error).message);
112
112
  }
113
113
 
114
114
  // Reset so re-picking the same file fires `change` again.
@@ -81,6 +81,21 @@
81
81
  );
82
82
 
83
83
  const bodyShellStyle = '';
84
+
85
+ // In scroll mode the page itself scrolls, so the sidenav has to be pinned below the
86
+ // header or it scrolls away with the content. Fixed mode already gives the row its own
87
+ // height and the aside its own scroll — sticky there would only shrink it to content.
88
+ const sidenavWrapClass = $derived(
89
+ _mode === 'fixed'
90
+ ? 'flex shrink-0'
91
+ : 'flex shrink-0 self-start sticky top-(--header-h) max-h-[calc(100svh-var(--header-h))]'
92
+ );
93
+
94
+ // The sticky wrapper is only as tall as its content, so in scroll mode the divider comes
95
+ // from the main column's left edge — that one spans the full body height.
96
+ const mainClass = $derived(
97
+ _mode === 'fixed' ? 'flex-1 overflow-y-auto' : 'flex-1 overflow-y-auto border-l border-border'
98
+ );
84
99
  </script>
85
100
 
86
101
  <div class={rootClass}>
@@ -110,8 +125,10 @@
110
125
 
111
126
  <div class={bodyShellClass} style={bodyShellStyle}>
112
127
  {#if sidenav}
113
- {@render sidenav()}
114
- <main class="flex-1 overflow-y-auto">
128
+ <div class={sidenavWrapClass}>
129
+ {@render sidenav()}
130
+ </div>
131
+ <main class={mainClass}>
115
132
  {@render children()}
116
133
  </main>
117
134
  {:else}
@@ -19,10 +19,22 @@
19
19
  eyebrow?: string;
20
20
  header?: Snippet;
21
21
  footer?: Snippet;
22
+ /**
23
+ * Off when the divider is drawn by the surrounding layout instead — a sticky sidenav
24
+ * only reaches as far as its own content, so its border would stop mid-page.
25
+ */
26
+ border?: boolean;
22
27
  class?: string;
23
28
  }
24
29
 
25
- let { items, eyebrow, header, footer, class: className = '' }: SideNavProps = $props();
30
+ let {
31
+ items,
32
+ eyebrow,
33
+ header,
34
+ footer,
35
+ border = true,
36
+ class: className = ''
37
+ }: SideNavProps = $props();
26
38
 
27
39
  function isActive(item: SideNavItem): boolean {
28
40
  const path = page.url.pathname;
@@ -32,7 +44,9 @@
32
44
  </script>
33
45
 
34
46
  <aside
35
- class={`w-60 flex shrink-0 flex-col overflow-y-auto border-r border-border bg-background ${className}`}
47
+ class={`w-60 flex h-full shrink-0 flex-col overflow-y-auto bg-background ${
48
+ border ? 'border-r border-border' : ''
49
+ } ${className}`}
36
50
  >
37
51
  {#if eyebrow}
38
52
  <div class="px-4 pt-5 pb-3">
@@ -12,6 +12,11 @@ interface SideNavProps {
12
12
  eyebrow?: string;
13
13
  header?: Snippet;
14
14
  footer?: Snippet;
15
+ /**
16
+ * Off when the divider is drawn by the surrounding layout instead — a sticky sidenav
17
+ * only reaches as far as its own content, so its border would stop mid-page.
18
+ */
19
+ border?: boolean;
15
20
  class?: string;
16
21
  }
17
22
  declare const SideNav: Component<SideNavProps, {}, "">;
@@ -2,6 +2,7 @@
2
2
  import { Input } from '../../primitives/input';
3
3
  import { Button } from '../../primitives/button';
4
4
  import { Label } from '../../primitives/label';
5
+ import { toast } from '../../primitives/sonner';
5
6
  import { FileUp, Link, CircleAlert, CircleCheck } from '@lucide/svelte';
6
7
  import { APP_DEFAULTS } from '../../../constants';
7
8
 
@@ -193,7 +194,7 @@
193
194
  const fileEnding = getFileExtension(file.name);
194
195
 
195
196
  if (!isValidFileExtension(fileEnding)) {
196
- alert(`File format not accepted: ${fileEnding}`);
197
+ toast.error(`File format not accepted: ${fileEnding}`);
197
198
  return;
198
199
  }
199
200
 
@@ -203,7 +204,7 @@
203
204
  // note in constants.ts) — some files pass here and still 413. The URL import
204
205
  // path applies the same cap.
205
206
  if (file.size > APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_BYTES) {
206
- alert(
207
+ toast.error(
207
208
  `File too large: ${(file.size / 1024 / 1024).toFixed(2)}MB (max ${APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_MB}MB).`
208
209
  );
209
210
  return;
@@ -0,0 +1,73 @@
1
+ <script lang="ts" module>
2
+ import { type VariantProps, tv } from 'tailwind-variants';
3
+ import { Info, TriangleAlert, CircleCheck, CircleX, Lightbulb } from '@lucide/svelte';
4
+ import type { Component } from 'svelte';
5
+
6
+ export const calloutVariants = tv({
7
+ base: 'flex items-start gap-2.5 rounded-lg border px-3.5 py-3 text-sm',
8
+ variants: {
9
+ tone: {
10
+ info: 'border-info/40 bg-info/10',
11
+ tip: 'border-border bg-muted/40',
12
+ success: 'border-success/40 bg-success/10',
13
+ warning: 'border-warning/40 bg-warning/10',
14
+ danger: 'border-destructive/40 bg-destructive/10'
15
+ }
16
+ },
17
+ defaultVariants: { tone: 'info' }
18
+ });
19
+
20
+ export type CalloutTone = NonNullable<VariantProps<typeof calloutVariants>['tone']>;
21
+
22
+ const TONE_ICON: Record<CalloutTone, Component> = {
23
+ info: Info,
24
+ tip: Lightbulb,
25
+ success: CircleCheck,
26
+ warning: TriangleAlert,
27
+ danger: CircleX
28
+ };
29
+
30
+ const TONE_ACCENT: Record<CalloutTone, string> = {
31
+ info: 'text-info',
32
+ tip: 'text-muted-foreground',
33
+ success: 'text-success',
34
+ warning: 'text-warning',
35
+ danger: 'text-destructive'
36
+ };
37
+ </script>
38
+
39
+ <script lang="ts">
40
+ import type { HTMLAttributes } from 'svelte/elements';
41
+ import type { Snippet } from 'svelte';
42
+ import { cn } from '../../utils.js';
43
+
44
+ interface Props extends HTMLAttributes<HTMLDivElement> {
45
+ tone?: CalloutTone;
46
+ title?: string;
47
+ /** Replaces the tone's default icon. `null` drops the icon entirely. */
48
+ icon?: Component | null;
49
+ children: Snippet;
50
+ }
51
+
52
+ let { tone = 'info', title, icon, class: className, children, ...restProps }: Props = $props();
53
+
54
+ const Icon = $derived(icon === undefined ? TONE_ICON[tone] : icon);
55
+ </script>
56
+
57
+ <div
58
+ class={cn(calloutVariants({ tone }), className)}
59
+ role={tone === 'warning' || tone === 'danger' ? 'alert' : undefined}
60
+ {...restProps}
61
+ >
62
+ {#if Icon}
63
+ <Icon class={cn('mt-0.5 h-4 w-4 shrink-0', TONE_ACCENT[tone])} />
64
+ {/if}
65
+ <div class="min-w-0 space-y-1">
66
+ {#if title}
67
+ <p class={cn('leading-tight font-medium', TONE_ACCENT[tone])}>{title}</p>
68
+ {/if}
69
+ <div class="leading-relaxed text-muted-foreground [&_a]:underline [&_strong]:text-foreground">
70
+ {@render children()}
71
+ </div>
72
+ </div>
73
+ </div>
@@ -0,0 +1,40 @@
1
+ import { type VariantProps } from 'tailwind-variants';
2
+ import type { Component } from 'svelte';
3
+ export declare const calloutVariants: import("tailwind-variants").TVReturnType<{
4
+ tone: {
5
+ info: "border-info/40 bg-info/10";
6
+ tip: "border-border bg-muted/40";
7
+ success: "border-success/40 bg-success/10";
8
+ warning: "border-warning/40 bg-warning/10";
9
+ danger: "border-destructive/40 bg-destructive/10";
10
+ };
11
+ }, undefined, "flex items-start gap-2.5 rounded-lg border px-3.5 py-3 text-sm", {
12
+ tone: {
13
+ info: "border-info/40 bg-info/10";
14
+ tip: "border-border bg-muted/40";
15
+ success: "border-success/40 bg-success/10";
16
+ warning: "border-warning/40 bg-warning/10";
17
+ danger: "border-destructive/40 bg-destructive/10";
18
+ };
19
+ }, undefined, import("tailwind-variants").TVReturnTypeLike<{
20
+ tone: {
21
+ info: "border-info/40 bg-info/10";
22
+ tip: "border-border bg-muted/40";
23
+ success: "border-success/40 bg-success/10";
24
+ warning: "border-warning/40 bg-warning/10";
25
+ danger: "border-destructive/40 bg-destructive/10";
26
+ };
27
+ }, undefined>>;
28
+ export type CalloutTone = NonNullable<VariantProps<typeof calloutVariants>['tone']>;
29
+ import type { HTMLAttributes } from 'svelte/elements';
30
+ import type { Snippet } from 'svelte';
31
+ interface Props extends HTMLAttributes<HTMLDivElement> {
32
+ tone?: CalloutTone;
33
+ title?: string;
34
+ /** Replaces the tone's default icon. `null` drops the icon entirely. */
35
+ icon?: Component | null;
36
+ children: Snippet;
37
+ }
38
+ declare const Callout: Component<Props, {}, "">;
39
+ type Callout = ReturnType<typeof Callout>;
40
+ export default Callout;
@@ -25,7 +25,7 @@
25
25
  bind:ref
26
26
  data-slot="dialog-content"
27
27
  class={cn(
28
- 'gap-4 p-6 shadow-lg data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] rounded-lg border bg-background duration-200',
28
+ 'gap-4 p-6 shadow-lg data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg fixed top-[50%] left-[50%] z-50 grid max-h-[85dvh] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] overflow-y-auto rounded-lg border bg-background duration-200',
29
29
  className
30
30
  )}
31
31
  {...restProps}
@@ -22,9 +22,11 @@ export { Switch } from './switch';
22
22
  export { Toaster, toast } from './sonner';
23
23
  export { ThemeSwitcher } from './theme-switcher';
24
24
  export { default as StateDisplay } from './StateDisplay.svelte';
25
+ export { default as Callout, calloutVariants, type CalloutTone } from './Callout.svelte';
25
26
  export { default as CalculateButton } from './CalculateButton.svelte';
26
27
  export { ModeToggle } from './mode-toggle';
27
28
  export { default as ViewToggle } from './ViewToggle.svelte';
28
29
  export { default as ImageUploadField } from './ImageUploadField.svelte';
29
30
  export { default as DataTable, type DataTableColumn } from './DataTable.svelte';
30
31
  export { default as FilterableDropdown, type FilterableDropdownItem } from './FilterableDropdown.svelte';
32
+ export { default as ConfirmDialog } from '../ConfirmDialog.svelte';
@@ -24,9 +24,11 @@ export { Toaster, toast } from './sonner';
24
24
  export { ThemeSwitcher } from './theme-switcher';
25
25
  // Custom components (not replaced by shadcn)
26
26
  export { default as StateDisplay } from './StateDisplay.svelte';
27
+ export { default as Callout, calloutVariants } from './Callout.svelte';
27
28
  export { default as CalculateButton } from './CalculateButton.svelte';
28
29
  export { ModeToggle } from './mode-toggle';
29
30
  export { default as ViewToggle } from './ViewToggle.svelte';
30
31
  export { default as ImageUploadField } from './ImageUploadField.svelte';
31
32
  export { default as DataTable } from './DataTable.svelte';
32
33
  export { default as FilterableDropdown } from './FilterableDropdown.svelte';
34
+ export { default as ConfirmDialog } from '../ConfirmDialog.svelte';
@@ -20,17 +20,34 @@
20
20
  */
21
21
  outliner: SceneOutliner;
22
22
  sceneVersion?: number;
23
+ /**
24
+ * Request a viewer repaint. Toggling `.visible` mutates three objects directly, and the
25
+ * render loop is on-demand — without this the canvas only catches up on its ~500ms idle
26
+ * repaint, so the row updates instantly and the geometry lags behind.
27
+ */
28
+ onVisibilityChange?: () => void;
23
29
  }
24
30
 
25
- let { outliner, sceneVersion = 0 }: Props = $props();
31
+ let { outliner, sceneVersion = 0, onVisibilityChange }: Props = $props();
32
+
33
+ const toggleLayer = (objects: THREE.Object3D[]) => {
34
+ outliner.visibility.toggleLayer(objects);
35
+ onVisibilityChange?.();
36
+ };
37
+
38
+ const toggleObject = (object: THREE.Object3D) => {
39
+ outliner.toggleObject(object);
40
+ onVisibilityChange?.();
41
+ };
26
42
 
27
43
  // Derived, not destructured: the prop is reassignable, and these must follow it.
28
44
  const hidden = $derived(outliner.visibility.hidden);
29
45
  const selected = $derived(outliner.selection.selected);
30
46
  const collapsed = $derived(outliner.collapsed);
31
47
 
32
- // Mirrored into runes because the outliner holds these as plain fields, not sets. Both are panel
33
- // state, so reopening clears them unlike hiding and collapse, which the outliner outlives.
48
+ // Panel state, so reopening clears it unlike hiding and collapse, which the outliner outlives.
49
+ // Owned here rather than on the outliner: writing it back from the derived below is what the
50
+ // Svelte 5 `state_unsafe_mutation` rule forbids.
34
51
  let searchQuery = $state('');
35
52
  let anchor = $state<string | null>(null);
36
53
 
@@ -45,9 +62,7 @@
45
62
 
46
63
  const layerGroups = $derived.by(() => {
47
64
  void sceneVersion;
48
- void searchQuery;
49
- outliner.searchQuery = searchQuery;
50
- return outliner.layerGroups();
65
+ return outliner.layerGroups(searchQuery);
51
66
  });
52
67
 
53
68
  // `SvelteSet.has()` is the reactive read, so go through the set rather than calling
@@ -62,10 +77,11 @@
62
77
  // Reading `anchor` keeps the shift-range dependent on it; the outliner owns the value.
63
78
  const selectObject = (uuid: string, event: MouseEvent) => {
64
79
  void anchor;
65
- outliner.select(uuid, {
66
- shiftKey: event.shiftKey,
67
- toggleKey: event.ctrlKey || event.metaKey
68
- });
80
+ outliner.select(
81
+ uuid,
82
+ { shiftKey: event.shiftKey, toggleKey: event.ctrlKey || event.metaKey },
83
+ searchQuery
84
+ );
69
85
  };
70
86
  </script>
71
87
 
@@ -110,7 +126,7 @@
110
126
 
111
127
  <button
112
128
  class="rounded p-1 shrink-0 transition-colors hover:bg-muted"
113
- onclick={() => outliner.visibility.toggleLayer(objects)}
129
+ onclick={() => toggleLayer(objects)}
114
130
  title={layerHidden ? t.showLayer : t.hideLayer}
115
131
  aria-label={layerHidden ? t.showLayer : t.hideLayer}
116
132
  >
@@ -162,7 +178,7 @@
162
178
  class="rounded p-1 shrink-0 transition-colors hover:bg-muted"
163
179
  onclick={(e) => {
164
180
  e.stopPropagation();
165
- outliner.toggleObject(object);
181
+ toggleObject(object);
166
182
  }}
167
183
  title={isHidden ? t.showObject : t.hideObject}
168
184
  aria-label={isHidden ? t.showObject : t.hideObject}
@@ -7,6 +7,12 @@ interface Props {
7
7
  */
8
8
  outliner: SceneOutliner;
9
9
  sceneVersion?: number;
10
+ /**
11
+ * Request a viewer repaint. Toggling `.visible` mutates three objects directly, and the
12
+ * render loop is on-demand — without this the canvas only catches up on its ~500ms idle
13
+ * repaint, so the row updates instantly and the geometry lags behind.
14
+ */
15
+ onVisibilityChange?: () => void;
10
16
  }
11
17
  declare const SceneManager: import("svelte").Component<Props, {}, "">;
12
18
  type SceneManager = ReturnType<typeof SceneManager>;
@@ -121,6 +121,7 @@
121
121
  let applyEdges: ((root: THREE.Object3D) => void) | null = null;
122
122
  let clearEdges: ((root: THREE.Object3D) => void) | null = null;
123
123
  let invalidate: (() => void) | null = null;
124
+ let captureImage: ThreeViewer['captureImage'] | null = null;
124
125
  let setLook: ((look: Look) => void) | null = null;
125
126
  let updateGridScale: (() => void) | null = null;
126
127
  let fitToView: (() => void) | null = null;
@@ -210,6 +211,7 @@
210
211
  applyEdges = init.applyEdges;
211
212
  clearEdges = init.clearEdges;
212
213
  invalidate = init.invalidate;
214
+ captureImage = init.captureImage;
213
215
  setLook = init.setLook;
214
216
  updateGridScale = init.updateGridScale;
215
217
  fitToView = init.fitToView;
@@ -322,23 +324,19 @@
322
324
  return usefulEntries.length > 0;
323
325
  }
324
326
 
325
- function downloadScreenshot() {
326
- if (!canvas) return;
327
-
328
- requestAnimationFrame(() => {
329
- canvas.toBlob((blob) => {
330
- if (!blob) return;
331
- const url = URL.createObjectURL(blob);
332
- const link = document.createElement('a');
333
- const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
334
- link.href = url;
335
- link.download = `viewer-${timestamp}.png`;
336
- document.body.appendChild(link);
337
- link.click();
338
- document.body.removeChild(link);
339
- URL.revokeObjectURL(url);
340
- });
341
- });
327
+ async function downloadScreenshot() {
328
+ const blob = await captureImage?.();
329
+ if (!blob) return;
330
+
331
+ const url = URL.createObjectURL(blob);
332
+ const link = document.createElement('a');
333
+ const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
334
+ link.href = url;
335
+ link.download = `viewer-${timestamp}.png`;
336
+ document.body.appendChild(link);
337
+ link.click();
338
+ document.body.removeChild(link);
339
+ URL.revokeObjectURL(url);
342
340
  }
343
341
  </script>
344
342
 
@@ -569,7 +567,7 @@
569
567
  {#if sceneManagerOpen && scene && outliner}
570
568
  <Resizable.Handle withHandle />
571
569
  <Resizable.Pane id="scene-manager" order={2} defaultSize={15} minSize={8} maxSize={30}>
572
- <SceneManager {outliner} {sceneVersion} />
570
+ <SceneManager {outliner} {sceneVersion} onVisibilityChange={() => invalidate?.()} />
573
571
  </Resizable.Pane>
574
572
  {/if}
575
573
  </Resizable.PaneGroup>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selvajs/ui",
3
- "version": "6.1.1",
3
+ "version": "6.2.0",
4
4
  "description": "Shared UI components and utilities for Selva applications",
5
5
  "license": "MIT",
6
6
  "author": "VektorNode",
@@ -59,10 +59,10 @@
59
59
  "svelte": "^5",
60
60
  "tailwind-variants": "^3.3.1",
61
61
  "three": "^0.185.1",
62
+ "@selvajs/schemas": "^5.0.1",
63
+ "@selvajs/solve": "^1.0.5",
62
64
  "@selvajs/compute": "^4.0.2",
63
- "@selvajs/schemas": "^5.0.0",
64
- "@selvajs/solve": "^1.0.3",
65
- "@selvajs/visualization": "^1.0.1"
65
+ "@selvajs/visualization": "^1.1.0"
66
66
  },
67
67
  "peerDependenciesMeta": {
68
68
  "three": {
@@ -90,11 +90,11 @@
90
90
  "svelte": "5.56.9",
91
91
  "tailwind-variants": "^3.3.1",
92
92
  "vitest": "^4.1.10",
93
- "@selvajs/config": "0.0.4",
94
- "@selvajs/schemas": "5.0.0",
95
93
  "@selvajs/compute": "4.0.2",
96
- "@selvajs/solve": "1.0.3",
97
- "@selvajs/visualization": "1.0.1"
94
+ "@selvajs/config": "0.0.4",
95
+ "@selvajs/schemas": "5.0.1",
96
+ "@selvajs/solve": "1.0.5",
97
+ "@selvajs/visualization": "1.1.0"
98
98
  },
99
99
  "scripts": {
100
100
  "predev": "node ../../scripts/sync-shared-assets.js",
@@ -109,6 +109,7 @@
109
109
  "test": "vitest run",
110
110
  "test:watch": "vitest",
111
111
  "format": "prettier --write .",
112
- "lint": "prettier --check . && eslint ."
112
+ "lint": "prettier --check . && eslint .",
113
+ "lint:types": "eslint . --config eslint.typed.config.js"
113
114
  }
114
115
  }
@@ -0,0 +1,59 @@
1
+ <script lang="ts">
2
+ import * as AlertDialog from './primitives/alert-dialog/index.js';
3
+
4
+ let {
5
+ open = $bindable(false),
6
+ title,
7
+ description,
8
+ confirmLabel = 'Continue',
9
+ pendingLabel,
10
+ cancelLabel = 'Cancel',
11
+ variant = 'default',
12
+ onConfirm
13
+ }: {
14
+ open?: boolean;
15
+ title: string;
16
+ description?: string;
17
+ confirmLabel?: string;
18
+ pendingLabel?: string;
19
+ cancelLabel?: string;
20
+ variant?: 'default' | 'destructive';
21
+ onConfirm: () => void | Promise<void>;
22
+ } = $props();
23
+
24
+ let pending = $state(false);
25
+
26
+ async function handleConfirm() {
27
+ pending = true;
28
+ try {
29
+ await onConfirm();
30
+ } finally {
31
+ pending = false;
32
+ }
33
+ }
34
+ </script>
35
+
36
+ <AlertDialog.Root bind:open onOpenChange={(next) => !pending && (open = next)}>
37
+ <AlertDialog.Content>
38
+ <AlertDialog.Header>
39
+ <AlertDialog.Title>{title}</AlertDialog.Title>
40
+ {#if description}
41
+ <AlertDialog.Description class={variant === 'destructive' ? 'text-destructive' : undefined}>
42
+ {description}
43
+ </AlertDialog.Description>
44
+ {/if}
45
+ </AlertDialog.Header>
46
+ <AlertDialog.Footer>
47
+ <AlertDialog.Cancel disabled={pending}>{cancelLabel}</AlertDialog.Cancel>
48
+ <AlertDialog.Action
49
+ onclick={handleConfirm}
50
+ disabled={pending}
51
+ class={variant === 'destructive'
52
+ ? 'text-destructive-foreground bg-destructive hover:bg-destructive/90'
53
+ : undefined}
54
+ >
55
+ {pending ? (pendingLabel ?? confirmLabel) : confirmLabel}
56
+ </AlertDialog.Action>
57
+ </AlertDialog.Footer>
58
+ </AlertDialog.Content>
59
+ </AlertDialog.Root>
@@ -72,6 +72,10 @@
72
72
  /** Viewer chrome and defaults. `backgroundColor` and `showSceneManager` are set by the layout. */
73
73
  viewerConfig?: ViewerConfig;
74
74
  headerRight?: Snippet;
75
+ // Primary nav rendered next to the brand, so the viewer keeps the app-wide nav.
76
+ navItems?: Snippet;
77
+ homeUrl?: string;
78
+ brandName?: string;
75
79
  // Replaces the built-in header; takes precedence over `headerRight`.
76
80
  header?: Snippet;
77
81
  // Scopes sessionStorage for external-input values; falls back to definitionKey then schema.id.
@@ -106,6 +110,9 @@
106
110
  footerItemId = 'footer-item',
107
111
  footerItemPriority = 0,
108
112
  headerRight,
113
+ navItems,
114
+ homeUrl,
115
+ brandName,
109
116
  header,
110
117
  onReady,
111
118
  onViewerReady,
@@ -215,6 +222,9 @@
215
222
  showFooter
216
223
  title={pageTitle}
217
224
  {showModeToggle}
225
+ {navItems}
226
+ {homeUrl}
227
+ {brandName}
218
228
  {copyrightName}
219
229
  {footerText}
220
230
  {header}
@@ -8,7 +8,7 @@
8
8
  exportStateAsJson,
9
9
  importStateFromJson
10
10
  } from '../../schema/param-exporter';
11
- import { Button, Input, Label, Textarea, Dialog, Card } from '../primitives';
11
+ import { Button, Input, Label, Textarea, Dialog, Card, toast } from '../primitives';
12
12
 
13
13
  import type { ActionButton } from '../../types/actionButton';
14
14
  import { DEFAULT_PRESET_LABELS, type PresetLabels } from '../../types/presetLabels';
@@ -68,7 +68,7 @@
68
68
 
69
69
  async function handleExport() {
70
70
  if (!exportName.trim()) {
71
- alert(t.saveNameRequired);
71
+ toast.error(t.saveNameRequired);
72
72
  return;
73
73
  }
74
74
 
@@ -108,7 +108,7 @@
108
108
  const imported = await importStateFromJson(input.files[0]);
109
109
  tryLoad(imported);
110
110
  } catch (error) {
111
- alert(t.loadImportError + (error as Error).message);
111
+ toast.error(t.loadImportError + (error as Error).message);
112
112
  }
113
113
 
114
114
  // Reset so re-picking the same file fires `change` again.
@@ -81,6 +81,21 @@
81
81
  );
82
82
 
83
83
  const bodyShellStyle = '';
84
+
85
+ // In scroll mode the page itself scrolls, so the sidenav has to be pinned below the
86
+ // header or it scrolls away with the content. Fixed mode already gives the row its own
87
+ // height and the aside its own scroll — sticky there would only shrink it to content.
88
+ const sidenavWrapClass = $derived(
89
+ _mode === 'fixed'
90
+ ? 'flex shrink-0'
91
+ : 'flex shrink-0 self-start sticky top-(--header-h) max-h-[calc(100svh-var(--header-h))]'
92
+ );
93
+
94
+ // The sticky wrapper is only as tall as its content, so in scroll mode the divider comes
95
+ // from the main column's left edge — that one spans the full body height.
96
+ const mainClass = $derived(
97
+ _mode === 'fixed' ? 'flex-1 overflow-y-auto' : 'flex-1 overflow-y-auto border-l border-border'
98
+ );
84
99
  </script>
85
100
 
86
101
  <div class={rootClass}>
@@ -110,8 +125,10 @@
110
125
 
111
126
  <div class={bodyShellClass} style={bodyShellStyle}>
112
127
  {#if sidenav}
113
- {@render sidenav()}
114
- <main class="flex-1 overflow-y-auto">
128
+ <div class={sidenavWrapClass}>
129
+ {@render sidenav()}
130
+ </div>
131
+ <main class={mainClass}>
115
132
  {@render children()}
116
133
  </main>
117
134
  {:else}
@@ -19,10 +19,22 @@
19
19
  eyebrow?: string;
20
20
  header?: Snippet;
21
21
  footer?: Snippet;
22
+ /**
23
+ * Off when the divider is drawn by the surrounding layout instead — a sticky sidenav
24
+ * only reaches as far as its own content, so its border would stop mid-page.
25
+ */
26
+ border?: boolean;
22
27
  class?: string;
23
28
  }
24
29
 
25
- let { items, eyebrow, header, footer, class: className = '' }: SideNavProps = $props();
30
+ let {
31
+ items,
32
+ eyebrow,
33
+ header,
34
+ footer,
35
+ border = true,
36
+ class: className = ''
37
+ }: SideNavProps = $props();
26
38
 
27
39
  function isActive(item: SideNavItem): boolean {
28
40
  const path = page.url.pathname;
@@ -32,7 +44,9 @@
32
44
  </script>
33
45
 
34
46
  <aside
35
- class={`w-60 flex shrink-0 flex-col overflow-y-auto border-r border-border bg-background ${className}`}
47
+ class={`w-60 flex h-full shrink-0 flex-col overflow-y-auto bg-background ${
48
+ border ? 'border-r border-border' : ''
49
+ } ${className}`}
36
50
  >
37
51
  {#if eyebrow}
38
52
  <div class="px-4 pt-5 pb-3">
@@ -2,6 +2,7 @@
2
2
  import { Input } from '$lib/components/primitives/input';
3
3
  import { Button } from '$lib/components/primitives/button';
4
4
  import { Label } from '$lib/components/primitives/label';
5
+ import { toast } from '$lib/components/primitives/sonner';
5
6
  import { FileUp, Link, CircleAlert, CircleCheck } from '@lucide/svelte';
6
7
  import { APP_DEFAULTS } from '$lib/constants';
7
8
 
@@ -193,7 +194,7 @@
193
194
  const fileEnding = getFileExtension(file.name);
194
195
 
195
196
  if (!isValidFileExtension(fileEnding)) {
196
- alert(`File format not accepted: ${fileEnding}`);
197
+ toast.error(`File format not accepted: ${fileEnding}`);
197
198
  return;
198
199
  }
199
200
 
@@ -203,7 +204,7 @@
203
204
  // note in constants.ts) — some files pass here and still 413. The URL import
204
205
  // path applies the same cap.
205
206
  if (file.size > APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_BYTES) {
206
- alert(
207
+ toast.error(
207
208
  `File too large: ${(file.size / 1024 / 1024).toFixed(2)}MB (max ${APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_MB}MB).`
208
209
  );
209
210
  return;
@@ -0,0 +1,73 @@
1
+ <script lang="ts" module>
2
+ import { type VariantProps, tv } from 'tailwind-variants';
3
+ import { Info, TriangleAlert, CircleCheck, CircleX, Lightbulb } from '@lucide/svelte';
4
+ import type { Component } from 'svelte';
5
+
6
+ export const calloutVariants = tv({
7
+ base: 'flex items-start gap-2.5 rounded-lg border px-3.5 py-3 text-sm',
8
+ variants: {
9
+ tone: {
10
+ info: 'border-info/40 bg-info/10',
11
+ tip: 'border-border bg-muted/40',
12
+ success: 'border-success/40 bg-success/10',
13
+ warning: 'border-warning/40 bg-warning/10',
14
+ danger: 'border-destructive/40 bg-destructive/10'
15
+ }
16
+ },
17
+ defaultVariants: { tone: 'info' }
18
+ });
19
+
20
+ export type CalloutTone = NonNullable<VariantProps<typeof calloutVariants>['tone']>;
21
+
22
+ const TONE_ICON: Record<CalloutTone, Component> = {
23
+ info: Info,
24
+ tip: Lightbulb,
25
+ success: CircleCheck,
26
+ warning: TriangleAlert,
27
+ danger: CircleX
28
+ };
29
+
30
+ const TONE_ACCENT: Record<CalloutTone, string> = {
31
+ info: 'text-info',
32
+ tip: 'text-muted-foreground',
33
+ success: 'text-success',
34
+ warning: 'text-warning',
35
+ danger: 'text-destructive'
36
+ };
37
+ </script>
38
+
39
+ <script lang="ts">
40
+ import type { HTMLAttributes } from 'svelte/elements';
41
+ import type { Snippet } from 'svelte';
42
+ import { cn } from '$lib/utils.js';
43
+
44
+ interface Props extends HTMLAttributes<HTMLDivElement> {
45
+ tone?: CalloutTone;
46
+ title?: string;
47
+ /** Replaces the tone's default icon. `null` drops the icon entirely. */
48
+ icon?: Component | null;
49
+ children: Snippet;
50
+ }
51
+
52
+ let { tone = 'info', title, icon, class: className, children, ...restProps }: Props = $props();
53
+
54
+ const Icon = $derived(icon === undefined ? TONE_ICON[tone] : icon);
55
+ </script>
56
+
57
+ <div
58
+ class={cn(calloutVariants({ tone }), className)}
59
+ role={tone === 'warning' || tone === 'danger' ? 'alert' : undefined}
60
+ {...restProps}
61
+ >
62
+ {#if Icon}
63
+ <Icon class={cn('mt-0.5 h-4 w-4 shrink-0', TONE_ACCENT[tone])} />
64
+ {/if}
65
+ <div class="min-w-0 space-y-1">
66
+ {#if title}
67
+ <p class={cn('leading-tight font-medium', TONE_ACCENT[tone])}>{title}</p>
68
+ {/if}
69
+ <div class="leading-relaxed text-muted-foreground [&_a]:underline [&_strong]:text-foreground">
70
+ {@render children()}
71
+ </div>
72
+ </div>
73
+ </div>
@@ -25,7 +25,7 @@
25
25
  bind:ref
26
26
  data-slot="dialog-content"
27
27
  class={cn(
28
- 'gap-4 p-6 shadow-lg data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] rounded-lg border bg-background duration-200',
28
+ 'gap-4 p-6 shadow-lg data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg fixed top-[50%] left-[50%] z-50 grid max-h-[85dvh] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] overflow-y-auto rounded-lg border bg-background duration-200',
29
29
  className
30
30
  )}
31
31
  {...restProps}
@@ -32,6 +32,7 @@ export { ThemeSwitcher } from './theme-switcher';
32
32
 
33
33
  // Custom components (not replaced by shadcn)
34
34
  export { default as StateDisplay } from './StateDisplay.svelte';
35
+ export { default as Callout, calloutVariants, type CalloutTone } from './Callout.svelte';
35
36
  export { default as CalculateButton } from './CalculateButton.svelte';
36
37
  export { ModeToggle } from './mode-toggle';
37
38
  export { default as ViewToggle } from './ViewToggle.svelte';
@@ -41,3 +42,4 @@ export {
41
42
  default as FilterableDropdown,
42
43
  type FilterableDropdownItem
43
44
  } from './FilterableDropdown.svelte';
45
+ export { default as ConfirmDialog } from '../ConfirmDialog.svelte';
@@ -20,17 +20,34 @@
20
20
  */
21
21
  outliner: SceneOutliner;
22
22
  sceneVersion?: number;
23
+ /**
24
+ * Request a viewer repaint. Toggling `.visible` mutates three objects directly, and the
25
+ * render loop is on-demand — without this the canvas only catches up on its ~500ms idle
26
+ * repaint, so the row updates instantly and the geometry lags behind.
27
+ */
28
+ onVisibilityChange?: () => void;
23
29
  }
24
30
 
25
- let { outliner, sceneVersion = 0 }: Props = $props();
31
+ let { outliner, sceneVersion = 0, onVisibilityChange }: Props = $props();
32
+
33
+ const toggleLayer = (objects: THREE.Object3D[]) => {
34
+ outliner.visibility.toggleLayer(objects);
35
+ onVisibilityChange?.();
36
+ };
37
+
38
+ const toggleObject = (object: THREE.Object3D) => {
39
+ outliner.toggleObject(object);
40
+ onVisibilityChange?.();
41
+ };
26
42
 
27
43
  // Derived, not destructured: the prop is reassignable, and these must follow it.
28
44
  const hidden = $derived(outliner.visibility.hidden);
29
45
  const selected = $derived(outliner.selection.selected);
30
46
  const collapsed = $derived(outliner.collapsed);
31
47
 
32
- // Mirrored into runes because the outliner holds these as plain fields, not sets. Both are panel
33
- // state, so reopening clears them unlike hiding and collapse, which the outliner outlives.
48
+ // Panel state, so reopening clears it unlike hiding and collapse, which the outliner outlives.
49
+ // Owned here rather than on the outliner: writing it back from the derived below is what the
50
+ // Svelte 5 `state_unsafe_mutation` rule forbids.
34
51
  let searchQuery = $state('');
35
52
  let anchor = $state<string | null>(null);
36
53
 
@@ -45,9 +62,7 @@
45
62
 
46
63
  const layerGroups = $derived.by(() => {
47
64
  void sceneVersion;
48
- void searchQuery;
49
- outliner.searchQuery = searchQuery;
50
- return outliner.layerGroups();
65
+ return outliner.layerGroups(searchQuery);
51
66
  });
52
67
 
53
68
  // `SvelteSet.has()` is the reactive read, so go through the set rather than calling
@@ -62,10 +77,11 @@
62
77
  // Reading `anchor` keeps the shift-range dependent on it; the outliner owns the value.
63
78
  const selectObject = (uuid: string, event: MouseEvent) => {
64
79
  void anchor;
65
- outliner.select(uuid, {
66
- shiftKey: event.shiftKey,
67
- toggleKey: event.ctrlKey || event.metaKey
68
- });
80
+ outliner.select(
81
+ uuid,
82
+ { shiftKey: event.shiftKey, toggleKey: event.ctrlKey || event.metaKey },
83
+ searchQuery
84
+ );
69
85
  };
70
86
  </script>
71
87
 
@@ -110,7 +126,7 @@
110
126
 
111
127
  <button
112
128
  class="rounded p-1 shrink-0 transition-colors hover:bg-muted"
113
- onclick={() => outliner.visibility.toggleLayer(objects)}
129
+ onclick={() => toggleLayer(objects)}
114
130
  title={layerHidden ? t.showLayer : t.hideLayer}
115
131
  aria-label={layerHidden ? t.showLayer : t.hideLayer}
116
132
  >
@@ -162,7 +178,7 @@
162
178
  class="rounded p-1 shrink-0 transition-colors hover:bg-muted"
163
179
  onclick={(e) => {
164
180
  e.stopPropagation();
165
- outliner.toggleObject(object);
181
+ toggleObject(object);
166
182
  }}
167
183
  title={isHidden ? t.showObject : t.hideObject}
168
184
  aria-label={isHidden ? t.showObject : t.hideObject}
@@ -121,6 +121,7 @@
121
121
  let applyEdges: ((root: THREE.Object3D) => void) | null = null;
122
122
  let clearEdges: ((root: THREE.Object3D) => void) | null = null;
123
123
  let invalidate: (() => void) | null = null;
124
+ let captureImage: ThreeViewer['captureImage'] | null = null;
124
125
  let setLook: ((look: Look) => void) | null = null;
125
126
  let updateGridScale: (() => void) | null = null;
126
127
  let fitToView: (() => void) | null = null;
@@ -210,6 +211,7 @@
210
211
  applyEdges = init.applyEdges;
211
212
  clearEdges = init.clearEdges;
212
213
  invalidate = init.invalidate;
214
+ captureImage = init.captureImage;
213
215
  setLook = init.setLook;
214
216
  updateGridScale = init.updateGridScale;
215
217
  fitToView = init.fitToView;
@@ -322,23 +324,19 @@
322
324
  return usefulEntries.length > 0;
323
325
  }
324
326
 
325
- function downloadScreenshot() {
326
- if (!canvas) return;
327
-
328
- requestAnimationFrame(() => {
329
- canvas.toBlob((blob) => {
330
- if (!blob) return;
331
- const url = URL.createObjectURL(blob);
332
- const link = document.createElement('a');
333
- const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
334
- link.href = url;
335
- link.download = `viewer-${timestamp}.png`;
336
- document.body.appendChild(link);
337
- link.click();
338
- document.body.removeChild(link);
339
- URL.revokeObjectURL(url);
340
- });
341
- });
327
+ async function downloadScreenshot() {
328
+ const blob = await captureImage?.();
329
+ if (!blob) return;
330
+
331
+ const url = URL.createObjectURL(blob);
332
+ const link = document.createElement('a');
333
+ const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
334
+ link.href = url;
335
+ link.download = `viewer-${timestamp}.png`;
336
+ document.body.appendChild(link);
337
+ link.click();
338
+ document.body.removeChild(link);
339
+ URL.revokeObjectURL(url);
342
340
  }
343
341
  </script>
344
342
 
@@ -569,7 +567,7 @@
569
567
  {#if sceneManagerOpen && scene && outliner}
570
568
  <Resizable.Handle withHandle />
571
569
  <Resizable.Pane id="scene-manager" order={2} defaultSize={15} minSize={8} maxSize={30}>
572
- <SceneManager {outliner} {sceneVersion} />
570
+ <SceneManager {outliner} {sceneVersion} onVisibilityChange={() => invalidate?.()} />
573
571
  </Resizable.Pane>
574
572
  {/if}
575
573
  </Resizable.PaneGroup>