@selvajs/ui 6.1.2 → 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) 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/index.d.ts +2 -0
  13. package/dist/components/primitives/index.js +2 -0
  14. package/dist/components/viewer/MeshMetadataDialog.svelte +1 -1
  15. package/dist/components/viewer/SceneManager.svelte +274 -103
  16. package/dist/components/viewer/SceneManager.svelte.d.ts +6 -0
  17. package/dist/components/viewer/Viewer.svelte +104 -32
  18. package/dist/i18n/messages.d.ts +4 -2
  19. package/dist/i18n/messages.js +8 -4
  20. package/package.json +14 -14
  21. package/src/lib/components/ConfirmDialog.svelte +59 -0
  22. package/src/lib/components/compute/ComputeApp.svelte +10 -0
  23. package/src/lib/components/compute/ParameterPresetManager.svelte +3 -3
  24. package/src/lib/components/layout/AppShell.svelte +19 -2
  25. package/src/lib/components/layout/SideNav.svelte +16 -2
  26. package/src/lib/components/preview/inputs/FileInput.svelte +3 -2
  27. package/src/lib/components/primitives/Callout.svelte +73 -0
  28. package/src/lib/components/primitives/index.ts +2 -0
  29. package/src/lib/components/viewer/MeshMetadataDialog.svelte +1 -1
  30. package/src/lib/components/viewer/SceneManager.svelte +274 -103
  31. package/src/lib/components/viewer/Viewer.svelte +104 -32
  32. package/src/lib/i18n/messages.ts +12 -6
@@ -5,6 +5,7 @@
5
5
  initThree,
6
6
  updateScene,
7
7
  LOOKS,
8
+ LOOK_PRESETS,
8
9
  type Look,
9
10
  type ThreeInitializerOptions,
10
11
  type CameraController,
@@ -118,11 +119,13 @@
118
119
  let cameraController: CameraController | null = null;
119
120
  let measureTool: MeasureTool | null = null;
120
121
  let grid: Grid | null = null;
121
- let applyEdges: ((root: THREE.Object3D) => void) | null = null;
122
- let clearEdges: ((root: THREE.Object3D) => void) | null = null;
122
+ let applyEdges: ThreeViewer['applyEdges'] | null = null;
123
+ let clearEdges: ThreeViewer['clearEdges'] | null = null;
123
124
  let invalidate: (() => void) | null = null;
125
+ let captureImage: ThreeViewer['captureImage'] | null = null;
124
126
  let setLook: ((look: Look) => void) | null = null;
125
127
  let updateGridScale: (() => void) | null = null;
128
+ let updateShadowBounds: (() => void) | null = null;
126
129
  let fitToView: (() => void) | null = null;
127
130
  let viewerInitialized = false;
128
131
  let sceneVersion = $state(0);
@@ -144,10 +147,15 @@
144
147
  let selectedMeshMetadata: Record<string, any> | null = $state(null);
145
148
  let selectedMeshName: string | null = $state(null);
146
149
 
147
- // Derived from LOOKS so adding a look in @selvajs/visualization shows up here with no edit.
150
+ // Derived from LOOKS so adding a look in @selvajs/visualization shows up here with no edit; the
151
+ // map only overrides the names that don't survive capitalisation ('xray' → 'X-Ray').
152
+ const LOOK_LABELS: Partial<Record<Look, string>> = {
153
+ xray: 'X-Ray',
154
+ lineart: 'Line Art'
155
+ };
148
156
  const STYLE_OPTIONS: { look: Look; label: string }[] = LOOKS.map((look) => ({
149
157
  look,
150
- label: look.charAt(0).toUpperCase() + look.slice(1)
158
+ label: LOOK_LABELS[look] ?? look.charAt(0).toUpperCase() + look.slice(1)
151
159
  }));
152
160
 
153
161
  const VIEW_PRESETS: { preset: ViewPreset; label: () => string }[] = [
@@ -175,12 +183,11 @@
175
183
  onMount(() => {
176
184
  if (!canvas) return;
177
185
 
178
- // Only what differs from the library defaults. The sun and shadows are off because the
179
- // technical look doesn't need themflat ambient plus the HDR environment carry it.
186
+ // Only what differs from the library defaults. Sunlight, shadows and AO are left ON (the
187
+ // library default) and their strength comes from the look with IBL alone every face of a
188
+ // box lights nearly equally and the model reads as a flat white silhouette.
180
189
  const opts: ThreeInitializerOptions = {
181
190
  look: renderStyle,
182
- lighting: { enableSunlight: false },
183
- render: { enableShadows: false },
184
191
  environment: { backgroundColor: config.backgroundColor },
185
192
  grid: { enabled: config.showToolsMenu && config.showGridToggle },
186
193
  measure: { enabled: config.showToolsMenu },
@@ -210,8 +217,10 @@
210
217
  applyEdges = init.applyEdges;
211
218
  clearEdges = init.clearEdges;
212
219
  invalidate = init.invalidate;
220
+ captureImage = init.captureImage;
213
221
  setLook = init.setLook;
214
222
  updateGridScale = init.updateGridScale;
223
+ updateShadowBounds = init.updateShadowBounds;
215
224
  fitToView = init.fitToView;
216
225
  projection = init.cameraController.getProjection();
217
226
 
@@ -253,22 +262,84 @@
253
262
  invalidate?.();
254
263
  }
255
264
 
265
+ // Edges the lineart look switched on, so leaving it can put the user's own choice back rather
266
+ // than stranding them with overlays they never asked for.
267
+ let edgesBeforeLineDrawing: boolean | null = null;
268
+
256
269
  function setRenderStyle(look: Look) {
257
270
  if (!setLook || look === renderStyle) return;
271
+ const wasLineDrawing = LOOK_PRESETS[renderStyle].requiresEdges === true;
258
272
  renderStyle = look;
259
273
  setLook(look);
274
+
275
+ // A look never drives the overlay itself; honouring `requiresEdges` is the host's call. Without
276
+ // edges lineart is blank white shapes, so entering it forces them on.
277
+ if (LOOK_PRESETS[look].requiresEdges) {
278
+ if (!wasLineDrawing) edgesBeforeLineDrawing = edgesVisible;
279
+ if (!edgesVisible) {
280
+ edgesVisible = true;
281
+ } else if (!wasLineDrawing && scene) {
282
+ // Already-attached overlays carry the old fade setting, and addEdges skips a mesh that
283
+ // has one — so they have to go before the un-faded pass can replace them.
284
+ clearEdges?.(scene);
285
+ }
286
+ applyEdgeState();
287
+ } else if (wasLineDrawing && edgesBeforeLineDrawing !== null) {
288
+ edgesVisible = edgesBeforeLineDrawing;
289
+ // Rebuild either way: staying on means swapping the un-faded overlays back for faded ones.
290
+ if (edgesVisible && scene) clearEdges?.(scene);
291
+ applyEdgeState();
292
+ edgesBeforeLineDrawing = null;
293
+ } else if (edgesVisible && scene) {
294
+ // Neither look is a line drawing, but an overlay's colour is derived from its mesh's
295
+ // material — which `setLook` just repainted. addEdges skips meshes that already have an
296
+ // overlay, so without a rebuild the edges keep the previous look's colour.
297
+ clearEdges?.(scene);
298
+ applyEdgeState();
299
+ }
300
+ }
301
+
302
+ // Meshes past the overlay budget get the screen-space fallback instead, and that pass thresholds
303
+ // a depth/normal discontinuity to decide what is an edge — so a shallow crease sitting near the
304
+ // cutoff flips on and off as the camera turns a degree. Tolerable as a hint over a shaded model;
305
+ // in a line drawing it is the picture itself flickering. Raised far enough that a building model
306
+ // gets real overlays throughout: they cost draw calls, but they are stable under orbit.
307
+ const LINE_ART_MAX_OVERLAYS = 20_000;
308
+
309
+ // Edge colour is normally derived per mesh from that mesh's own material, darkened. lineart
310
+ // repaints every material near-white, so a derived edge lands at rgb(62,62,62) or lighter — and
311
+ // worse, `setLook` and `applyEdges` are independent, so whether the derivation sees the white
312
+ // override or the model's original colours depends on which ran last. That is the real source of
313
+ // the "edges look different every time" behaviour. Forcing a colour makes it deterministic.
314
+ const LINE_ART_EDGE_COLOR = 0x1a1d21;
315
+
316
+ // The density fade sets opacity per overlay, so a finely-detailed mesh fades as a whole and its
317
+ // long silhouette edges go with it — in a line drawing that erases the only thing on screen, and
318
+ // it recomputes per frame, so edges pop while orbiting. Shaded looks keep it: there it just
319
+ // softens outlines on a model you can still see.
320
+ function edgeOverrides() {
321
+ return LOOK_PRESETS[renderStyle].requiresEdges
322
+ ? {
323
+ distanceFade: false,
324
+ maxOverlays: LINE_ART_MAX_OVERLAYS,
325
+ color: LINE_ART_EDGE_COLOR
326
+ }
327
+ : undefined;
260
328
  }
261
329
 
262
330
  // `applyEdges` is idempotent per mesh, so the repeated calls after each solve add no duplicate
263
331
  // overlays; `clearEdges` is its inverse.
264
332
  function applyEdgeState() {
265
333
  if (!scene) return;
266
- if (edgesVisible) applyEdges?.(scene);
334
+ if (edgesVisible) applyEdges?.(scene, edgeOverrides());
267
335
  else clearEdges?.(scene);
268
336
  }
269
337
 
270
338
  function toggleEdges() {
271
339
  edgesVisible = !edgesVisible;
340
+ // Toggling by hand inside lineart overrides what that look forced, so the pre-look value is
341
+ // stale — leaving the look must not undo the user's more recent explicit choice.
342
+ if (LOOK_PRESETS[renderStyle].requiresEdges) edgesBeforeLineDrawing = null;
272
343
  applyEdgeState();
273
344
  }
274
345
 
@@ -278,10 +349,16 @@
278
349
  // Untracked because toggleEdges() already handles the toggle directly — reading
279
350
  // `edgesVisible` tracked here would re-trigger a full solve.
280
351
  untrack(() => {
352
+ // The new meshes carry the materials the parser built, so a look that overrides them
353
+ // (arctic, x-ray) has to be re-applied or the solve silently reverts to shaded.
354
+ setLook?.(renderStyle);
281
355
  // updateScene discarded the previous solve's overlays along with its content.
282
- if (edgesVisible) applyEdges?.(scene!);
356
+ if (edgesVisible) applyEdges?.(scene!, edgeOverrides());
283
357
  // Rescale the grid so cells and fade match the new content's extent.
284
358
  updateGridScale?.();
359
+ // The shadow frustum is sized to scene content, so it has to follow the new geometry —
360
+ // left at the old extent, shadows go blocky or fall outside the map entirely.
361
+ updateShadowBounds?.();
285
362
  // The rebuild un-hid everything; the outliner keys hidden state on Grasshopper
286
363
  // identity, not on the instances just discarded, so it can re-hide it.
287
364
  outliner?.applyTo();
@@ -311,8 +388,7 @@
311
388
  const EXCLUDED_KEYS = new Set([
312
389
  'name',
313
390
  'layer',
314
- 'originalIndex',
315
- 'sourceComponentId',
391
+ 'id',
316
392
  'vertexCount',
317
393
  'faceCount',
318
394
  'vertexOffset',
@@ -322,23 +398,19 @@
322
398
  return usefulEntries.length > 0;
323
399
  }
324
400
 
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
- });
401
+ async function downloadScreenshot() {
402
+ const blob = await captureImage?.();
403
+ if (!blob) return;
404
+
405
+ const url = URL.createObjectURL(blob);
406
+ const link = document.createElement('a');
407
+ const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
408
+ link.href = url;
409
+ link.download = `viewer-${timestamp}.png`;
410
+ document.body.appendChild(link);
411
+ link.click();
412
+ document.body.removeChild(link);
413
+ URL.revokeObjectURL(url);
342
414
  }
343
415
  </script>
344
416
 
@@ -422,10 +494,10 @@
422
494
  <DropdownMenu.Item class={itemClass} onSelect={toggleProjection}>
423
495
  {#if projection === 'perspective'}
424
496
  <Square class="h-4 w-4" />
425
- {t.switchTo2D}
497
+ {t.switchToOrthographic}
426
498
  {:else}
427
499
  <Box class="h-4 w-4" />
428
- {t.switchTo3D}
500
+ {t.switchToPerspective}
429
501
  {/if}
430
502
  </DropdownMenu.Item>
431
503
 
@@ -569,7 +641,7 @@
569
641
  {#if sceneManagerOpen && scene && outliner}
570
642
  <Resizable.Handle withHandle />
571
643
  <Resizable.Pane id="scene-manager" order={2} defaultSize={15} minSize={8} maxSize={30}>
572
- <SceneManager {outliner} {sceneVersion} />
644
+ <SceneManager {outliner} {sceneVersion} onVisibilityChange={() => invalidate?.()} />
573
645
  </Resizable.Pane>
574
646
  {/if}
575
647
  </Resizable.PaneGroup>
@@ -1,8 +1,8 @@
1
1
  export type Locale = 'en' | 'de';
2
2
  export interface ViewerMessages {
3
3
  toolsMenu: string;
4
- switchTo2D: string;
5
- switchTo3D: string;
4
+ switchToOrthographic: string;
5
+ switchToPerspective: string;
6
6
  fitToView: string;
7
7
  views: string;
8
8
  measure: string;
@@ -24,6 +24,8 @@ export interface ViewerMessages {
24
24
  clearSearch: string;
25
25
  expandLayer: string;
26
26
  collapseLayer: string;
27
+ expandAll: string;
28
+ collapseAll: string;
27
29
  showLayer: string;
28
30
  hideLayer: string;
29
31
  showObject: string;
@@ -7,8 +7,8 @@
7
7
  // cannot be translated here.
8
8
  const en = {
9
9
  toolsMenu: 'Viewer tools',
10
- switchTo2D: 'Switch to 2D',
11
- switchTo3D: 'Switch to 3D',
10
+ switchToOrthographic: 'Orthographic camera',
11
+ switchToPerspective: 'Perspective camera',
12
12
  fitToView: 'Fit to view',
13
13
  views: 'Views',
14
14
  measure: 'Measure',
@@ -30,6 +30,8 @@ const en = {
30
30
  clearSearch: 'Clear search',
31
31
  expandLayer: 'Expand layer',
32
32
  collapseLayer: 'Collapse layer',
33
+ expandAll: 'Expand all',
34
+ collapseAll: 'Collapse all',
33
35
  showLayer: 'Show layer',
34
36
  hideLayer: 'Hide layer',
35
37
  showObject: 'Show object',
@@ -56,8 +58,8 @@ const en = {
56
58
  };
57
59
  const de = {
58
60
  toolsMenu: 'Viewer-Werkzeuge',
59
- switchTo2D: 'Zu 2D wechseln',
60
- switchTo3D: 'Zu 3D wechseln',
61
+ switchToOrthographic: 'Orthografische Kamera',
62
+ switchToPerspective: 'Perspektivische Kamera',
61
63
  fitToView: 'Ansicht anpassen',
62
64
  views: 'Ansichten',
63
65
  measure: 'Messen',
@@ -79,6 +81,8 @@ const de = {
79
81
  clearSearch: 'Suche löschen',
80
82
  expandLayer: 'Ebene aufklappen',
81
83
  collapseLayer: 'Ebene zuklappen',
84
+ expandAll: 'Alle aufklappen',
85
+ collapseAll: 'Alle zuklappen',
82
86
  showLayer: 'Ebene einblenden',
83
87
  hideLayer: 'Ebene ausblenden',
84
88
  showObject: 'Objekt einblenden',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selvajs/ui",
3
- "version": "6.1.2",
3
+ "version": "6.3.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/compute": "^4.0.2",
63
- "@selvajs/solve": "^1.0.4",
64
- "@selvajs/visualization": "^1.0.1",
65
- "@selvajs/schemas": "^5.0.1"
62
+ "@selvajs/compute": "^4.1.1",
63
+ "@selvajs/visualization": "^1.2.0",
64
+ "@selvajs/schemas": "^5.0.2",
65
+ "@selvajs/solve": "^1.0.8"
66
66
  },
67
67
  "peerDependenciesMeta": {
68
68
  "three": {
@@ -72,7 +72,7 @@
72
72
  "dependencies": {
73
73
  "@floating-ui/dom": "^1.8.0",
74
74
  "@iconify/svelte": "^5.2.1",
75
- "@lucide/svelte": "^1.30.0",
75
+ "@lucide/svelte": "^1.35.0",
76
76
  "clsx": "^2.1.1",
77
77
  "mode-watcher": "^1.1.0",
78
78
  "paneforge": "^1.0.2",
@@ -82,19 +82,19 @@
82
82
  },
83
83
  "devDependencies": {
84
84
  "@internationalized/date": "^3.12.3",
85
- "@sveltejs/kit": "2.70.2",
85
+ "@sveltejs/kit": "2.70.3",
86
86
  "@sveltejs/vite-plugin-svelte": "^7.3.0",
87
87
  "@types/three": "^0.185.4",
88
- "bits-ui": "^2.18.0",
88
+ "bits-ui": "^2.19.0",
89
89
  "rimraf": "^6.1.3",
90
- "svelte": "5.56.9",
90
+ "svelte": "5.56.10",
91
91
  "tailwind-variants": "^3.3.1",
92
- "vitest": "^4.1.10",
92
+ "vitest": "^4.1.11",
93
+ "@selvajs/compute": "4.1.1",
93
94
  "@selvajs/config": "0.0.4",
94
- "@selvajs/schemas": "5.0.1",
95
- "@selvajs/visualization": "1.0.1",
96
- "@selvajs/solve": "1.0.4",
97
- "@selvajs/compute": "4.0.2"
95
+ "@selvajs/visualization": "1.2.0",
96
+ "@selvajs/schemas": "5.0.2",
97
+ "@selvajs/solve": "1.0.8"
98
98
  },
99
99
  "scripts": {
100
100
  "predev": "node ../../scripts/sync-shared-assets.js",
@@ -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>
@@ -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';
@@ -13,7 +13,7 @@
13
13
  isFullscreen?: boolean;
14
14
  }
15
15
 
16
- const EXCLUDED_KEYS = new Set(['name', 'layer', 'originalIndex', 'sourceComponentId']);
16
+ const EXCLUDED_KEYS = new Set(['name', 'layer', 'id']);
17
17
 
18
18
  let {
19
19
  open = $bindable(),