@selvajs/ui 0.9.1 → 0.9.3

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.
@@ -156,7 +156,9 @@
156
156
 
157
157
  <div
158
158
  data-layout-root
159
- class="min-h-0 sm:flex-row sm:px-(--page-px) sm:py-(--page-py) flex flex-1 flex-col overflow-hidden"
159
+ class="min-h-0 sm:flex-row sm:py-(--page-py) flex flex-1 flex-col overflow-hidden {leftCollapsed
160
+ ? 'sm:pl-0'
161
+ : 'sm:pl-(--page-px)'} {rightCollapsed ? 'sm:pr-0' : 'sm:pr-(--page-px)'}"
160
162
  class:fullscreen-layout={isViewerFullscreen}
161
163
  class:relative={isMobile}
162
164
  >
@@ -304,6 +306,7 @@
304
306
  collapsedSize={0}
305
307
  onCollapse={() => (leftCollapsed = true)}
306
308
  onExpand={() => (leftCollapsed = false)}
309
+ class="ml-1"
307
310
  >
308
311
  <div
309
312
  class="min-h-0 flex h-full flex-col {isViewerFullscreen || leftCollapsed
@@ -314,7 +317,12 @@
314
317
  </div>
315
318
  </Resizable.Pane>
316
319
  <Resizable.Handle
317
- class={leftCollapsed ? 'pointer-events-none hidden' : 'bg-transparent'}
320
+ withHandle
321
+ class="bg-transparent"
322
+ data-collapsed={leftCollapsed}
323
+ onclick={() => {
324
+ if (leftCollapsed) leftPaneRef?.expand();
325
+ }}
318
326
  />
319
327
  {/if}
320
328
 
@@ -335,7 +343,12 @@
335
343
  <!-- Right pane -->
336
344
  {#if hasRightPanel}
337
345
  <Resizable.Handle
338
- class={rightCollapsed ? ' pointer-events-none hidden' : 'bg-transparent'}
346
+ withHandle
347
+ class="bg-transparent"
348
+ data-collapsed={rightCollapsed}
349
+ onclick={() => {
350
+ if (rightCollapsed) rightPaneRef?.expand();
351
+ }}
339
352
  />
340
353
  <Resizable.Pane
341
354
  bind:this={rightPaneRef}
@@ -347,6 +360,7 @@
347
360
  collapsedSize={0}
348
361
  onCollapse={() => (rightCollapsed = true)}
349
362
  onExpand={() => (rightCollapsed = false)}
363
+ class="mr-1"
350
364
  >
351
365
  <div
352
366
  class="min-h-0 flex h-full flex-col {isViewerFullscreen || rightCollapsed
@@ -386,6 +400,12 @@
386
400
  padding: 0 !important;
387
401
  }
388
402
 
403
+ /* When the adjacent pane is collapsed, override paneforge's inline
404
+ ew-resize cursor — there's nothing to drag from in this state. */
405
+ :global([data-pane-resizer][data-collapsed='true']) {
406
+ cursor: pointer !important;
407
+ }
408
+
389
409
  .panel-content-wrapper {
390
410
  display: flex;
391
411
  flex-direction: column;
@@ -1,6 +1,5 @@
1
1
  <script lang="ts">
2
2
  import type { TabConfig } from '@selvajs/schemas';
3
- import { ChevronRight, ChevronLeft, ChevronDown } from '@lucide/svelte';
4
3
  import Icon from '@iconify/svelte';
5
4
 
6
5
  interface Props {
@@ -12,29 +11,34 @@
12
11
  }
13
12
 
14
13
  let { side, tabs, collapsedWidth, onExpand, onTabClick }: Props = $props();
14
+
15
+ const railClass = $derived(
16
+ [
17
+ 'gap-1 py-2 lg:py-3 lg:flex-col lg:w-auto px-2 lg:px-0',
18
+ 'flex w-full shrink-0 flex-row items-center',
19
+ 'backdrop-blur-sm bg-background/90 cursor-pointer'
20
+ ].join(' ')
21
+ );
22
+
23
+ const tabButtonClass =
24
+ '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';
15
25
  </script>
16
26
 
17
27
  <div
18
- class="gap-2 py-3 lg:py-4 lg:flex-col lg:w-auto px-3 lg:px-0 lg:rounded-md flex w-full shrink-0 cursor-pointer flex-row items-center border-2 border-border bg-muted transition-colors hover:bg-muted/70"
19
- style="lg:width: {collapsedWidth}px"
28
+ class={railClass}
29
+ style="--collapsed-w: {collapsedWidth}px;"
20
30
  role="button"
21
31
  tabindex="0"
32
+ aria-label="Expand {side} panel"
22
33
  onclick={onExpand}
23
- onkeydown={(e) => e.key === 'Enter' && onExpand()}
24
- title="Expand {side} panel"
34
+ onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && onExpand()}
25
35
  >
26
- <!-- Mobile chevron (top of row) -->
27
- {#if side === 'left'}
28
- <div class="lg:hidden text-muted-foreground">
29
- <ChevronDown size={14} />
30
- </div>
31
- {/if}
32
-
33
36
  {#each tabs as tab (tab.id)}
34
37
  <button
35
38
  type="button"
36
- class="w-8 h-8 m-1 rounded text-xs font-semibold shadow-sm flex shrink-0 items-center justify-center bg-background text-foreground transition-colors select-none hover:bg-accent/80"
39
+ class={tabButtonClass}
37
40
  title={tab.label}
41
+ aria-label={tab.label}
38
42
  onclick={(e) => {
39
43
  e.stopPropagation();
40
44
  onTabClick(tab.id);
@@ -44,21 +48,22 @@
44
48
  {#if tab.icon.includes(':')}
45
49
  <Icon icon={tab.icon} class="h-4 w-4" />
46
50
  {:else}
47
- <span>{tab.icon}</span>
51
+ <span class="text-xs font-semibold">{tab.icon}</span>
48
52
  {/if}
49
53
  {:else}
50
- <span>{tab.label[0]?.toUpperCase() ?? '?'}</span>
54
+ <span class="text-xs font-semibold">{tab.label[0]?.toUpperCase() ?? '?'}</span>
51
55
  {/if}
52
56
  </button>
53
57
  {/each}
54
-
55
- {#if side === 'left'}
56
- <div class="lg:block mt-auto hidden text-muted-foreground">
57
- <ChevronRight size={14} />
58
- </div>
59
- {:else if side === 'right'}
60
- <div class="lg:block mt-auto hidden text-muted-foreground">
61
- <ChevronLeft size={14} />
62
- </div>
63
- {/if}
64
58
  </div>
59
+
60
+ <style>
61
+ div {
62
+ min-width: 0;
63
+ }
64
+ @media (min-width: 1024px) {
65
+ div {
66
+ width: var(--collapsed-w);
67
+ }
68
+ }
69
+ </style>
@@ -12,6 +12,7 @@
12
12
  import AppShell from '../layout/AppShell.svelte';
13
13
  import AppLayout from './AppLayout.svelte';
14
14
  import StateDisplay from '../primitives/StateDisplay.svelte';
15
+ import { getExternalInputs, readExternalValue } from '../../external/storage';
15
16
 
16
17
  import type { Snippet } from 'svelte';
17
18
 
@@ -34,6 +35,11 @@
34
35
  footerItemPriority?: number;
35
36
  onReady?: (api: { loadValues: (values: Record<string, unknown>) => void }) => void;
36
37
  headerRight?: Snippet;
38
+ /**
39
+ * Stable identifier used to scope sessionStorage entries for external-input
40
+ * values. If absent, falls back to definitionKey, then to schema.id.
41
+ */
42
+ externalScopeKey?: string;
37
43
  }
38
44
 
39
45
  let {
@@ -53,12 +59,22 @@
53
59
  footerItemId = 'footer-item',
54
60
  footerItemPriority = 0,
55
61
  headerRight,
56
- onReady
62
+ onReady,
63
+ externalScopeKey
57
64
  }: Props = $props();
58
65
 
59
- function createInitialValues(s: UISchema) {
66
+ const resolvedScopeKey = $derived(externalScopeKey || definitionKey || schema?.id || '');
67
+
68
+ function createInitialValues(s: UISchema, scopeKey: string) {
69
+ const externalSet = new Set(getExternalInputs(s).map((e) => e.paramId));
60
70
  const v: Record<string, unknown> = {};
61
71
  for (const input of s.inputs) {
72
+ if (externalSet.has(input.id)) {
73
+ const stored = readExternalValue({ scopeKey, inputId: input.id });
74
+ if (stored !== undefined) v[input.id] = stored;
75
+ // else: leave undefined so the missing-inputs panel can detect it
76
+ continue;
77
+ }
62
78
  v[input.id] = input.default ?? getDefaultValue(input.paramType);
63
79
  }
64
80
  for (const output of s.outputs) {
@@ -68,7 +84,9 @@
68
84
  }
69
85
 
70
86
  // svelte-ignore state_referenced_locally
71
- let values = $state<Record<string, unknown>>(createInitialValues(schema));
87
+ let values = $state<Record<string, unknown>>(
88
+ createInitialValues(schema, externalScopeKey || definitionKey || schema?.id || '')
89
+ );
72
90
  let error = $state('');
73
91
  let computeErrors = $state<string[]>([]);
74
92
  let computeWarnings = $state<string[]>([]);
@@ -144,7 +162,7 @@
144
162
  }
145
163
  } else if (previousDefinitionKey !== definitionKey) {
146
164
  meshes = [];
147
- values = createInitialValues(schema);
165
+ values = createInitialValues(schema, resolvedScopeKey);
148
166
  error = '';
149
167
  computeErrors = [];
150
168
  computeWarnings = [];
@@ -23,6 +23,11 @@ interface Props {
23
23
  loadValues: (values: Record<string, unknown>) => void;
24
24
  }) => void;
25
25
  headerRight?: Snippet;
26
+ /**
27
+ * Stable identifier used to scope sessionStorage entries for external-input
28
+ * values. If absent, falls back to definitionKey, then to schema.id.
29
+ */
30
+ externalScopeKey?: string;
26
31
  }
27
32
  declare const ComputeApp: import("svelte").Component<Props, {}, "">;
28
33
  type ComputeApp = ReturnType<typeof ComputeApp>;
@@ -43,13 +43,40 @@
43
43
  onToggle();
44
44
  }
45
45
  }
46
+
47
+ /**
48
+ * Compute the column position (0-indexed) where each item starts, accounting
49
+ * for spans and linebreak resets. Items hidden by visibility don't consume
50
+ * a slot (matching what the renderer does — they're skipped entirely).
51
+ */
52
+ const columnStarts = $derived.by(() => {
53
+ const positions: number[] = [];
54
+ let col = 0;
55
+ for (const item of items) {
56
+ if (item.type === 'linebreak') {
57
+ positions.push(0);
58
+ col = 0;
59
+ continue;
60
+ }
61
+ const visibility = evaluateVisibility(item, values);
62
+ if (!visibility.visible) {
63
+ positions.push(0);
64
+ continue;
65
+ }
66
+ const span = Math.min(Math.max(1, item.span ?? 1), columns);
67
+ if (col + span > columns) col = 0;
68
+ positions.push(col);
69
+ col = (col + span) % columns;
70
+ }
71
+ return positions;
72
+ });
46
73
  </script>
47
74
 
48
75
  {#if flat}
49
76
  <div class="p-6">
50
77
  <div class="schema-grid gap-6 grid" style="--schema-cols: {columns};">
51
- {#each items as layoutItem (layoutItem.type === 'linebreak' ? layoutItem.id : layoutItem.paramId)}
52
- {@render gridItem(layoutItem, columns)}
78
+ {#each items as layoutItem, i (layoutItem.type === 'linebreak' ? layoutItem.id : layoutItem.paramId)}
79
+ {@render gridItem(layoutItem, columns, columnStarts[i] === 0)}
53
80
  {/each}
54
81
  </div>
55
82
  </div>
@@ -79,8 +106,8 @@
79
106
  <div class="content-inner">
80
107
  <Card.Content class="p-6">
81
108
  <div class="schema-grid gap-6 grid" style="--schema-cols: {columns};">
82
- {#each items as layoutItem (layoutItem.type === 'linebreak' ? layoutItem.id : layoutItem.paramId)}
83
- {@render gridItem(layoutItem, columns)}
109
+ {#each items as layoutItem, i (layoutItem.type === 'linebreak' ? layoutItem.id : layoutItem.paramId)}
110
+ {@render gridItem(layoutItem, columns, columnStarts[i] === 0)}
84
111
  {/each}
85
112
  </div>
86
113
  </Card.Content>
@@ -89,7 +116,7 @@
89
116
  </Card.Root>
90
117
  {/if}
91
118
 
92
- {#snippet gridItem(layoutItem: LayoutItem, cols: number)}
119
+ {#snippet gridItem(layoutItem: LayoutItem, cols: number, isFirstInRow: boolean)}
93
120
  {#if layoutItem.type === 'linebreak'}
94
121
  <div style="grid-column: 1 / -1" class="h-px bg-border" aria-hidden="true"></div>
95
122
  {:else}
@@ -98,14 +125,19 @@
98
125
  {#if visibility.visible}
99
126
  {#if layoutItem.type === 'input'}
100
127
  <div
101
- class="min-w-0 flex items-center"
128
+ class="grid-cell min-w-0 flex items-center"
102
129
  class:opacity-50={visibility.disabled}
130
+ class:col-divider={!isFirstInRow && cols > 1}
103
131
  style="grid-column: span {span} / span {span}"
104
132
  >
105
133
  {@render inputSnippet(layoutItem, visibility)}
106
134
  </div>
107
135
  {:else if layoutItem.type === 'output'}
108
- <div style="grid-column: span {span} / span {span}">
136
+ <div
137
+ class="grid-cell"
138
+ class:col-divider={!isFirstInRow && cols > 1}
139
+ style="grid-column: span {span} / span {span}"
140
+ >
109
141
  {@render outputSnippet(layoutItem)}
110
142
  </div>
111
143
  {/if}
@@ -131,4 +163,22 @@
131
163
  .schema-grid {
132
164
  grid-template-columns: repeat(var(--schema-cols), minmax(0, 1fr));
133
165
  }
166
+
167
+ /* Vertical divider painted in the column gap. `gap-6` is 24px, so a 12px
168
+ negative margin + 12px padding centres the 1px line in the gutter. */
169
+ .col-divider {
170
+ border-left: 1px solid var(--border);
171
+ padding-left: 12px;
172
+ margin-left: -12px;
173
+ }
174
+
175
+ /* When the container collapses the grid to 1 visual column, the items are
176
+ stacked and the divider would float on the left of every row. Hide it. */
177
+ @container (max-width: 320px) {
178
+ .col-divider {
179
+ border-left: 0;
180
+ padding-left: 0;
181
+ margin-left: 0;
182
+ }
183
+ }
134
184
  </style>
@@ -41,6 +41,21 @@
41
41
  const inputId = $derived(`input-${item.paramId}`);
42
42
  const label = $derived(displayName || item.displayName || item.paramId);
43
43
 
44
+ // Number range hint — shown next to label for sliders, under the input for plain number fields.
45
+ const numberRangeHint = $derived.by(() => {
46
+ if (!isNumberWidget(item)) return null;
47
+ const cfg = item.config;
48
+ if (cfg?.hideRange) return null;
49
+ const hasMin = typeof cfg?.minimum === 'number';
50
+ const hasMax = typeof cfg?.maximum === 'number';
51
+ if (!hasMin && !hasMax) return null;
52
+ if (hasMin && hasMax) return `${cfg!.minimum} to ${cfg!.maximum}`;
53
+ if (hasMin) return `≥ ${cfg!.minimum}`;
54
+ return `≤ ${cfg!.maximum}`;
55
+ });
56
+
57
+ const showRangeInLabel = $derived(isNumberWidget(item) && numberRangeHint !== null);
58
+
44
59
  function commit(newValue: SupportedTypes) {
45
60
  value = newValue;
46
61
  onChange(item.paramId, newValue);
@@ -63,6 +78,9 @@
63
78
  </Dialog.Content>
64
79
  </Dialog.Root>
65
80
  {/if}
81
+ {#if showRangeInLabel}
82
+ <span class="text-xs font-normal text-muted-foreground">{numberRangeHint}</span>
83
+ {/if}
66
84
  </Field.Label>
67
85
 
68
86
  {#if isNumberWidget(item)}
@@ -63,7 +63,8 @@
63
63
 
64
64
  function buildTree(files: FileData[]): SvelteMap<string, TreeNode> {
65
65
  const root = new SvelteMap<string, TreeNode>();
66
- for (const file of files) {
66
+ for (let i = 0; i < files.length; i++) {
67
+ const file = files[i];
67
68
  const parts = (file.subFolder || '').split('/').filter(Boolean);
68
69
  let current = root;
69
70
  for (const part of parts) {
@@ -73,7 +74,9 @@
73
74
  const node = current.get(part)!;
74
75
  if (node.type === 'folder') current = node.children;
75
76
  }
76
- current.set(file.fileName + file.fileType, { type: 'file', file });
77
+ const baseKey = `${file.fileName}${file.fileType ?? ''}`;
78
+ const key = current.has(baseKey) ? `${baseKey}#${i}` : baseKey;
79
+ current.set(key, { type: 'file', file });
77
80
  }
78
81
  return root;
79
82
  }
@@ -93,6 +96,24 @@
93
96
  const hasSubFolders = $derived(filesArray.some((f) => f.subFolder && f.subFolder.length > 0));
94
97
  const fileTree = $derived(hasSubFolders ? buildTree(filesArray) : null);
95
98
 
99
+ function fullPath(f: FileData): string {
100
+ const folder = (f.subFolder || '').replace(/^\/+|\/+$/g, '');
101
+ const name = `${f.fileName}${f.fileType ?? ''}`;
102
+ return folder ? `${folder}/${name}` : name;
103
+ }
104
+
105
+ const duplicatePaths = $derived.by(() => {
106
+ const seen = new Map<string, number>();
107
+ for (const f of filesArray) {
108
+ const key = fullPath(f);
109
+ seen.set(key, (seen.get(key) ?? 0) + 1);
110
+ }
111
+ return Array.from(seen.entries())
112
+ .filter(([, n]) => n > 1)
113
+ .map(([path]) => path);
114
+ });
115
+ const hasDuplicates = $derived(duplicatePaths.length > 0);
116
+
96
117
  // All folders expanded by default
97
118
  let expandedFolders = new SvelteSet<string>();
98
119
 
@@ -220,7 +241,7 @@
220
241
  {@render treeNodes(fileTree, '')}
221
242
  {:else}
222
243
  <div class="gap-1 flex flex-col">
223
- {#each filesArray as file (file.fileName + file.fileType)}
244
+ {#each filesArray as file, i (fullPath(file) + '#' + i)}
224
245
  <div
225
246
  class="gap-2 text-xs flex items-center justify-between text-muted-foreground"
226
247
  >
@@ -233,6 +254,24 @@
233
254
  </div>
234
255
  {/if}
235
256
  </div>
257
+ {#if hasDuplicates}
258
+ <div
259
+ class="rounded px-3 py-2 text-sm border border-destructive bg-destructive/10 text-destructive"
260
+ >
261
+ <div class="font-medium">Duplicate file names detected</div>
262
+ <div class="mt-1 text-xs">
263
+ The following {duplicatePaths.length === 1 ? 'path appears' : 'paths appear'} more than once:
264
+ </div>
265
+ <ul class="mt-1 ml-4 text-xs list-disc">
266
+ {#each duplicatePaths as path (path)}
267
+ <li class="truncate"><span class="font-mono">{path}</span></li>
268
+ {/each}
269
+ </ul>
270
+ <div class="mt-1 text-xs">
271
+ Rename outputs in your Grasshopper definition so each file has a unique name.
272
+ </div>
273
+ </div>
274
+ {/if}
236
275
  {#if downloadError}
237
276
  <div
238
277
  class="rounded px-3 py-2 text-sm border border-destructive bg-destructive/10 text-destructive"
@@ -240,7 +279,11 @@
240
279
  {downloadError}
241
280
  </div>
242
281
  {/if}
243
- <Button onclick={handleDownload} disabled={downloading} class="w-full">
282
+ <Button
283
+ onclick={handleDownload}
284
+ disabled={downloading || hasDuplicates}
285
+ class="w-full"
286
+ >
244
287
  {downloading
245
288
  ? 'Downloading...'
246
289
  : `Download ${fileCount === 1 ? 'File' : `${fileCount} Files`}`}
@@ -10,9 +10,26 @@
10
10
  }
11
11
 
12
12
  let { tabs, onTabChange }: Props = $props();
13
+
14
+ let viewportRef = $state<HTMLElement | null>(null);
15
+
16
+ function handleWheel(e: WheelEvent) {
17
+ if (!viewportRef) return;
18
+ // Trackpad horizontal swipes already produce deltaX — let those pass through.
19
+ if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return;
20
+ const canScroll = viewportRef.scrollWidth > viewportRef.clientWidth;
21
+ if (!canScroll) return;
22
+ e.preventDefault();
23
+ viewportRef.scrollLeft += e.deltaY;
24
+ }
13
25
  </script>
14
26
 
15
- <ScrollArea class="w-full shrink-0 border-b border-border" orientation="horizontal">
27
+ <ScrollArea
28
+ bind:viewportRef
29
+ class="w-full shrink-0 border-b border-border"
30
+ orientation="horizontal"
31
+ onwheel={handleWheel}
32
+ >
16
33
  <Tabs.List
17
34
  class="px-2 py-2 gap-0 inline-flex h-auto w-max justify-start rounded-none bg-transparent"
18
35
  >
@@ -16,12 +16,14 @@
16
16
  bind:ref
17
17
  data-slot="resizable-handle"
18
18
  class={cn(
19
- 'cn-resizable-handle after:inset-y-0 after:w-1 data-[direction=vertical]:after:left-0 data-[direction=vertical]:after:h-1 data-[direction=vertical]:after:translate-x-0 relative flex w-px items-center justify-center bg-border after:absolute after:left-1/2 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:outline-hidden data-[direction=vertical]:h-px data-[direction=vertical]:w-full data-[direction=vertical]:after:w-full data-[direction=vertical]:after:-translate-y-1/2 [&[data-direction=vertical]>div]:rotate-90',
19
+ 'cn-resizable-handle group/handle after:inset-y-0 after:w-1 data-[direction=vertical]:after:left-0 data-[direction=vertical]:after:h-1 data-[direction=vertical]:after:translate-x-0 relative flex w-px items-center justify-center bg-border after:absolute after:left-1/2 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:outline-hidden data-[direction=vertical]:h-px data-[direction=vertical]:w-full data-[direction=vertical]:after:w-full data-[direction=vertical]:after:-translate-y-1/2 [&[data-direction=vertical]>div]:rotate-90',
20
20
  className
21
21
  )}
22
22
  {...restProps}
23
23
  >
24
24
  {#if withHandle}
25
- <div class="h-6 w-1 z-10 flex shrink-0 rounded-lg bg-border"></div>
25
+ <div
26
+ class="h-10 w-1 z-10 flex shrink-0 rounded-lg bg-border transition-[background-color,transform] duration-150 group-hover/handle:bg-foreground/40 group-hover/handle:scale-y-110 group-active/handle:bg-foreground/60"
27
+ ></div>
26
28
  {/if}
27
29
  </ResizablePrimitive.PaneResizer>
@@ -0,0 +1,15 @@
1
+ import type { UISchema } from '@selvajs/schemas';
2
+ export interface ExternalValueRef {
3
+ scopeKey: string;
4
+ inputId: string;
5
+ }
6
+ export declare function writeExternalValue(args: ExternalValueRef & {
7
+ value: unknown;
8
+ }): void;
9
+ export declare function readExternalValue(ref: ExternalValueRef): unknown | undefined;
10
+ export declare function clearExternalValue(ref: ExternalValueRef): void;
11
+ export interface ExternalInput {
12
+ paramId: string;
13
+ displayName: string;
14
+ }
15
+ export declare function getExternalInputs(schema: UISchema): ExternalInput[];
@@ -0,0 +1,70 @@
1
+ // External-input transit storage.
2
+ //
3
+ // When an input has source.kind === 'external', a producer route writes the produced
4
+ // value here, and the solver route reads it back. Scoped per (scopeKey, inputId) so
5
+ // values for one solver/input don't bleed into another. The scope key is whatever
6
+ // uniquely identifies the solver context — sessionId in builder-app/preview,
7
+ // definition guid in compute-app/library, etc.
8
+ //
9
+ // inputId is the Grasshopper parameter instance GUID (LayoutItem.paramId / SchemaInput.id).
10
+ const STORAGE_PREFIX = 'external';
11
+ function makeKey(scopeKey, inputId) {
12
+ return `${STORAGE_PREFIX}:${scopeKey}:${inputId}`;
13
+ }
14
+ export function writeExternalValue(args) {
15
+ const { scopeKey, inputId, value } = args;
16
+ if (!scopeKey || !inputId)
17
+ return;
18
+ if (typeof sessionStorage === 'undefined')
19
+ return;
20
+ sessionStorage.setItem(makeKey(scopeKey, inputId), JSON.stringify(value));
21
+ }
22
+ export function readExternalValue(ref) {
23
+ const { scopeKey, inputId } = ref;
24
+ if (!scopeKey || !inputId)
25
+ return undefined;
26
+ if (typeof sessionStorage === 'undefined')
27
+ return undefined;
28
+ const raw = sessionStorage.getItem(makeKey(scopeKey, inputId));
29
+ if (raw === null)
30
+ return undefined;
31
+ try {
32
+ return JSON.parse(raw);
33
+ }
34
+ catch {
35
+ return undefined;
36
+ }
37
+ }
38
+ export function clearExternalValue(ref) {
39
+ const { scopeKey, inputId } = ref;
40
+ if (!scopeKey || !inputId)
41
+ return;
42
+ if (typeof sessionStorage === 'undefined')
43
+ return;
44
+ sessionStorage.removeItem(makeKey(scopeKey, inputId));
45
+ }
46
+ function* walkLayoutItems(schema) {
47
+ const groups = schema.layout.type === 'tabbed'
48
+ ? schema.layout.tabs.flatMap((t) => t.groups)
49
+ : schema.layout.groups;
50
+ for (const group of groups) {
51
+ for (const item of group.items) {
52
+ yield item;
53
+ }
54
+ }
55
+ }
56
+ export function getExternalInputs(schema) {
57
+ const result = [];
58
+ for (const item of walkLayoutItems(schema)) {
59
+ if (item.type !== 'input')
60
+ continue;
61
+ const source = item.source;
62
+ if (source?.kind !== 'external')
63
+ continue;
64
+ result.push({
65
+ paramId: item.paramId,
66
+ displayName: item.displayName ?? item.paramId
67
+ });
68
+ }
69
+ return result;
70
+ }
package/dist/index.d.ts CHANGED
@@ -7,6 +7,7 @@ export { default as StateDisplay } from './components/primitives/StateDisplay.sv
7
7
  export { default as Viewer } from './components/viewer/Viewer.svelte';
8
8
  export * from './schema/defaults';
9
9
  export * from './compute/solving.svelte';
10
+ export * from './external/storage';
10
11
  export * from './contexts/footerContext.svelte';
11
12
  export * from './composables/useFooterItem.svelte';
12
13
  export * from './utils';
package/dist/index.js CHANGED
@@ -13,6 +13,8 @@ export { default as Viewer } from './components/viewer/Viewer.svelte';
13
13
  // Utilities
14
14
  export * from './schema/defaults';
15
15
  export * from './compute/solving.svelte';
16
+ // External-input transit storage (used by routes that wire pre-step producers)
17
+ export * from './external/storage';
16
18
  // Contexts & Composables
17
19
  export * from './contexts/footerContext.svelte';
18
20
  export * from './composables/useFooterItem.svelte';
@@ -47,6 +47,9 @@ const ACTIONS = {
47
47
  export function evaluateVisibility(item, values) {
48
48
  if (item.type === 'linebreak')
49
49
  return { visible: true, disabled: false };
50
+ if ('visible' in item && item.visible === false) {
51
+ return { visible: false, disabled: false };
52
+ }
50
53
  if (!item.visibilityCondition?.rules)
51
54
  return { visible: true, disabled: false };
52
55
  const { action = 'show', defaultValue } = item.visibilityCondition;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selvajs/ui",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "description": "Shared UI components and utilities for Selva applications",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -156,7 +156,9 @@
156
156
 
157
157
  <div
158
158
  data-layout-root
159
- class="min-h-0 sm:flex-row sm:px-(--page-px) sm:py-(--page-py) flex flex-1 flex-col overflow-hidden"
159
+ class="min-h-0 sm:flex-row sm:py-(--page-py) flex flex-1 flex-col overflow-hidden {leftCollapsed
160
+ ? 'sm:pl-0'
161
+ : 'sm:pl-(--page-px)'} {rightCollapsed ? 'sm:pr-0' : 'sm:pr-(--page-px)'}"
160
162
  class:fullscreen-layout={isViewerFullscreen}
161
163
  class:relative={isMobile}
162
164
  >
@@ -304,6 +306,7 @@
304
306
  collapsedSize={0}
305
307
  onCollapse={() => (leftCollapsed = true)}
306
308
  onExpand={() => (leftCollapsed = false)}
309
+ class="ml-1"
307
310
  >
308
311
  <div
309
312
  class="min-h-0 flex h-full flex-col {isViewerFullscreen || leftCollapsed
@@ -314,7 +317,12 @@
314
317
  </div>
315
318
  </Resizable.Pane>
316
319
  <Resizable.Handle
317
- class={leftCollapsed ? 'pointer-events-none hidden' : 'bg-transparent'}
320
+ withHandle
321
+ class="bg-transparent"
322
+ data-collapsed={leftCollapsed}
323
+ onclick={() => {
324
+ if (leftCollapsed) leftPaneRef?.expand();
325
+ }}
318
326
  />
319
327
  {/if}
320
328
 
@@ -335,7 +343,12 @@
335
343
  <!-- Right pane -->
336
344
  {#if hasRightPanel}
337
345
  <Resizable.Handle
338
- class={rightCollapsed ? ' pointer-events-none hidden' : 'bg-transparent'}
346
+ withHandle
347
+ class="bg-transparent"
348
+ data-collapsed={rightCollapsed}
349
+ onclick={() => {
350
+ if (rightCollapsed) rightPaneRef?.expand();
351
+ }}
339
352
  />
340
353
  <Resizable.Pane
341
354
  bind:this={rightPaneRef}
@@ -347,6 +360,7 @@
347
360
  collapsedSize={0}
348
361
  onCollapse={() => (rightCollapsed = true)}
349
362
  onExpand={() => (rightCollapsed = false)}
363
+ class="mr-1"
350
364
  >
351
365
  <div
352
366
  class="min-h-0 flex h-full flex-col {isViewerFullscreen || rightCollapsed
@@ -386,6 +400,12 @@
386
400
  padding: 0 !important;
387
401
  }
388
402
 
403
+ /* When the adjacent pane is collapsed, override paneforge's inline
404
+ ew-resize cursor — there's nothing to drag from in this state. */
405
+ :global([data-pane-resizer][data-collapsed='true']) {
406
+ cursor: pointer !important;
407
+ }
408
+
389
409
  .panel-content-wrapper {
390
410
  display: flex;
391
411
  flex-direction: column;
@@ -1,6 +1,5 @@
1
1
  <script lang="ts">
2
2
  import type { TabConfig } from '@selvajs/schemas';
3
- import { ChevronRight, ChevronLeft, ChevronDown } from '@lucide/svelte';
4
3
  import Icon from '@iconify/svelte';
5
4
 
6
5
  interface Props {
@@ -12,29 +11,34 @@
12
11
  }
13
12
 
14
13
  let { side, tabs, collapsedWidth, onExpand, onTabClick }: Props = $props();
14
+
15
+ const railClass = $derived(
16
+ [
17
+ 'gap-1 py-2 lg:py-3 lg:flex-col lg:w-auto px-2 lg:px-0',
18
+ 'flex w-full shrink-0 flex-row items-center',
19
+ 'backdrop-blur-sm bg-background/90 cursor-pointer'
20
+ ].join(' ')
21
+ );
22
+
23
+ const tabButtonClass =
24
+ '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';
15
25
  </script>
16
26
 
17
27
  <div
18
- class="gap-2 py-3 lg:py-4 lg:flex-col lg:w-auto px-3 lg:px-0 lg:rounded-md flex w-full shrink-0 cursor-pointer flex-row items-center border-2 border-border bg-muted transition-colors hover:bg-muted/70"
19
- style="lg:width: {collapsedWidth}px"
28
+ class={railClass}
29
+ style="--collapsed-w: {collapsedWidth}px;"
20
30
  role="button"
21
31
  tabindex="0"
32
+ aria-label="Expand {side} panel"
22
33
  onclick={onExpand}
23
- onkeydown={(e) => e.key === 'Enter' && onExpand()}
24
- title="Expand {side} panel"
34
+ onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && onExpand()}
25
35
  >
26
- <!-- Mobile chevron (top of row) -->
27
- {#if side === 'left'}
28
- <div class="lg:hidden text-muted-foreground">
29
- <ChevronDown size={14} />
30
- </div>
31
- {/if}
32
-
33
36
  {#each tabs as tab (tab.id)}
34
37
  <button
35
38
  type="button"
36
- class="w-8 h-8 m-1 rounded text-xs font-semibold shadow-sm flex shrink-0 items-center justify-center bg-background text-foreground transition-colors select-none hover:bg-accent/80"
39
+ class={tabButtonClass}
37
40
  title={tab.label}
41
+ aria-label={tab.label}
38
42
  onclick={(e) => {
39
43
  e.stopPropagation();
40
44
  onTabClick(tab.id);
@@ -44,21 +48,22 @@
44
48
  {#if tab.icon.includes(':')}
45
49
  <Icon icon={tab.icon} class="h-4 w-4" />
46
50
  {:else}
47
- <span>{tab.icon}</span>
51
+ <span class="text-xs font-semibold">{tab.icon}</span>
48
52
  {/if}
49
53
  {:else}
50
- <span>{tab.label[0]?.toUpperCase() ?? '?'}</span>
54
+ <span class="text-xs font-semibold">{tab.label[0]?.toUpperCase() ?? '?'}</span>
51
55
  {/if}
52
56
  </button>
53
57
  {/each}
54
-
55
- {#if side === 'left'}
56
- <div class="lg:block mt-auto hidden text-muted-foreground">
57
- <ChevronRight size={14} />
58
- </div>
59
- {:else if side === 'right'}
60
- <div class="lg:block mt-auto hidden text-muted-foreground">
61
- <ChevronLeft size={14} />
62
- </div>
63
- {/if}
64
58
  </div>
59
+
60
+ <style>
61
+ div {
62
+ min-width: 0;
63
+ }
64
+ @media (min-width: 1024px) {
65
+ div {
66
+ width: var(--collapsed-w);
67
+ }
68
+ }
69
+ </style>
@@ -12,6 +12,7 @@
12
12
  import AppShell from '../layout/AppShell.svelte';
13
13
  import AppLayout from './AppLayout.svelte';
14
14
  import StateDisplay from '../primitives/StateDisplay.svelte';
15
+ import { getExternalInputs, readExternalValue } from '../../external/storage';
15
16
 
16
17
  import type { Snippet } from 'svelte';
17
18
 
@@ -34,6 +35,11 @@
34
35
  footerItemPriority?: number;
35
36
  onReady?: (api: { loadValues: (values: Record<string, unknown>) => void }) => void;
36
37
  headerRight?: Snippet;
38
+ /**
39
+ * Stable identifier used to scope sessionStorage entries for external-input
40
+ * values. If absent, falls back to definitionKey, then to schema.id.
41
+ */
42
+ externalScopeKey?: string;
37
43
  }
38
44
 
39
45
  let {
@@ -53,12 +59,22 @@
53
59
  footerItemId = 'footer-item',
54
60
  footerItemPriority = 0,
55
61
  headerRight,
56
- onReady
62
+ onReady,
63
+ externalScopeKey
57
64
  }: Props = $props();
58
65
 
59
- function createInitialValues(s: UISchema) {
66
+ const resolvedScopeKey = $derived(externalScopeKey || definitionKey || schema?.id || '');
67
+
68
+ function createInitialValues(s: UISchema, scopeKey: string) {
69
+ const externalSet = new Set(getExternalInputs(s).map((e) => e.paramId));
60
70
  const v: Record<string, unknown> = {};
61
71
  for (const input of s.inputs) {
72
+ if (externalSet.has(input.id)) {
73
+ const stored = readExternalValue({ scopeKey, inputId: input.id });
74
+ if (stored !== undefined) v[input.id] = stored;
75
+ // else: leave undefined so the missing-inputs panel can detect it
76
+ continue;
77
+ }
62
78
  v[input.id] = input.default ?? getDefaultValue(input.paramType);
63
79
  }
64
80
  for (const output of s.outputs) {
@@ -68,7 +84,9 @@
68
84
  }
69
85
 
70
86
  // svelte-ignore state_referenced_locally
71
- let values = $state<Record<string, unknown>>(createInitialValues(schema));
87
+ let values = $state<Record<string, unknown>>(
88
+ createInitialValues(schema, externalScopeKey || definitionKey || schema?.id || '')
89
+ );
72
90
  let error = $state('');
73
91
  let computeErrors = $state<string[]>([]);
74
92
  let computeWarnings = $state<string[]>([]);
@@ -144,7 +162,7 @@
144
162
  }
145
163
  } else if (previousDefinitionKey !== definitionKey) {
146
164
  meshes = [];
147
- values = createInitialValues(schema);
165
+ values = createInitialValues(schema, resolvedScopeKey);
148
166
  error = '';
149
167
  computeErrors = [];
150
168
  computeWarnings = [];
@@ -43,13 +43,40 @@
43
43
  onToggle();
44
44
  }
45
45
  }
46
+
47
+ /**
48
+ * Compute the column position (0-indexed) where each item starts, accounting
49
+ * for spans and linebreak resets. Items hidden by visibility don't consume
50
+ * a slot (matching what the renderer does — they're skipped entirely).
51
+ */
52
+ const columnStarts = $derived.by(() => {
53
+ const positions: number[] = [];
54
+ let col = 0;
55
+ for (const item of items) {
56
+ if (item.type === 'linebreak') {
57
+ positions.push(0);
58
+ col = 0;
59
+ continue;
60
+ }
61
+ const visibility = evaluateVisibility(item, values);
62
+ if (!visibility.visible) {
63
+ positions.push(0);
64
+ continue;
65
+ }
66
+ const span = Math.min(Math.max(1, item.span ?? 1), columns);
67
+ if (col + span > columns) col = 0;
68
+ positions.push(col);
69
+ col = (col + span) % columns;
70
+ }
71
+ return positions;
72
+ });
46
73
  </script>
47
74
 
48
75
  {#if flat}
49
76
  <div class="p-6">
50
77
  <div class="schema-grid gap-6 grid" style="--schema-cols: {columns};">
51
- {#each items as layoutItem (layoutItem.type === 'linebreak' ? layoutItem.id : layoutItem.paramId)}
52
- {@render gridItem(layoutItem, columns)}
78
+ {#each items as layoutItem, i (layoutItem.type === 'linebreak' ? layoutItem.id : layoutItem.paramId)}
79
+ {@render gridItem(layoutItem, columns, columnStarts[i] === 0)}
53
80
  {/each}
54
81
  </div>
55
82
  </div>
@@ -79,8 +106,8 @@
79
106
  <div class="content-inner">
80
107
  <Card.Content class="p-6">
81
108
  <div class="schema-grid gap-6 grid" style="--schema-cols: {columns};">
82
- {#each items as layoutItem (layoutItem.type === 'linebreak' ? layoutItem.id : layoutItem.paramId)}
83
- {@render gridItem(layoutItem, columns)}
109
+ {#each items as layoutItem, i (layoutItem.type === 'linebreak' ? layoutItem.id : layoutItem.paramId)}
110
+ {@render gridItem(layoutItem, columns, columnStarts[i] === 0)}
84
111
  {/each}
85
112
  </div>
86
113
  </Card.Content>
@@ -89,7 +116,7 @@
89
116
  </Card.Root>
90
117
  {/if}
91
118
 
92
- {#snippet gridItem(layoutItem: LayoutItem, cols: number)}
119
+ {#snippet gridItem(layoutItem: LayoutItem, cols: number, isFirstInRow: boolean)}
93
120
  {#if layoutItem.type === 'linebreak'}
94
121
  <div style="grid-column: 1 / -1" class="h-px bg-border" aria-hidden="true"></div>
95
122
  {:else}
@@ -98,14 +125,19 @@
98
125
  {#if visibility.visible}
99
126
  {#if layoutItem.type === 'input'}
100
127
  <div
101
- class="min-w-0 flex items-center"
128
+ class="grid-cell min-w-0 flex items-center"
102
129
  class:opacity-50={visibility.disabled}
130
+ class:col-divider={!isFirstInRow && cols > 1}
103
131
  style="grid-column: span {span} / span {span}"
104
132
  >
105
133
  {@render inputSnippet(layoutItem, visibility)}
106
134
  </div>
107
135
  {:else if layoutItem.type === 'output'}
108
- <div style="grid-column: span {span} / span {span}">
136
+ <div
137
+ class="grid-cell"
138
+ class:col-divider={!isFirstInRow && cols > 1}
139
+ style="grid-column: span {span} / span {span}"
140
+ >
109
141
  {@render outputSnippet(layoutItem)}
110
142
  </div>
111
143
  {/if}
@@ -131,4 +163,22 @@
131
163
  .schema-grid {
132
164
  grid-template-columns: repeat(var(--schema-cols), minmax(0, 1fr));
133
165
  }
166
+
167
+ /* Vertical divider painted in the column gap. `gap-6` is 24px, so a 12px
168
+ negative margin + 12px padding centres the 1px line in the gutter. */
169
+ .col-divider {
170
+ border-left: 1px solid var(--border);
171
+ padding-left: 12px;
172
+ margin-left: -12px;
173
+ }
174
+
175
+ /* When the container collapses the grid to 1 visual column, the items are
176
+ stacked and the divider would float on the left of every row. Hide it. */
177
+ @container (max-width: 320px) {
178
+ .col-divider {
179
+ border-left: 0;
180
+ padding-left: 0;
181
+ margin-left: 0;
182
+ }
183
+ }
134
184
  </style>
@@ -41,6 +41,21 @@
41
41
  const inputId = $derived(`input-${item.paramId}`);
42
42
  const label = $derived(displayName || item.displayName || item.paramId);
43
43
 
44
+ // Number range hint — shown next to label for sliders, under the input for plain number fields.
45
+ const numberRangeHint = $derived.by(() => {
46
+ if (!isNumberWidget(item)) return null;
47
+ const cfg = item.config;
48
+ if (cfg?.hideRange) return null;
49
+ const hasMin = typeof cfg?.minimum === 'number';
50
+ const hasMax = typeof cfg?.maximum === 'number';
51
+ if (!hasMin && !hasMax) return null;
52
+ if (hasMin && hasMax) return `${cfg!.minimum} to ${cfg!.maximum}`;
53
+ if (hasMin) return `≥ ${cfg!.minimum}`;
54
+ return `≤ ${cfg!.maximum}`;
55
+ });
56
+
57
+ const showRangeInLabel = $derived(isNumberWidget(item) && numberRangeHint !== null);
58
+
44
59
  function commit(newValue: SupportedTypes) {
45
60
  value = newValue;
46
61
  onChange(item.paramId, newValue);
@@ -63,6 +78,9 @@
63
78
  </Dialog.Content>
64
79
  </Dialog.Root>
65
80
  {/if}
81
+ {#if showRangeInLabel}
82
+ <span class="text-xs font-normal text-muted-foreground">{numberRangeHint}</span>
83
+ {/if}
66
84
  </Field.Label>
67
85
 
68
86
  {#if isNumberWidget(item)}
@@ -63,7 +63,8 @@
63
63
 
64
64
  function buildTree(files: FileData[]): SvelteMap<string, TreeNode> {
65
65
  const root = new SvelteMap<string, TreeNode>();
66
- for (const file of files) {
66
+ for (let i = 0; i < files.length; i++) {
67
+ const file = files[i];
67
68
  const parts = (file.subFolder || '').split('/').filter(Boolean);
68
69
  let current = root;
69
70
  for (const part of parts) {
@@ -73,7 +74,9 @@
73
74
  const node = current.get(part)!;
74
75
  if (node.type === 'folder') current = node.children;
75
76
  }
76
- current.set(file.fileName + file.fileType, { type: 'file', file });
77
+ const baseKey = `${file.fileName}${file.fileType ?? ''}`;
78
+ const key = current.has(baseKey) ? `${baseKey}#${i}` : baseKey;
79
+ current.set(key, { type: 'file', file });
77
80
  }
78
81
  return root;
79
82
  }
@@ -238,7 +241,7 @@
238
241
  {@render treeNodes(fileTree, '')}
239
242
  {:else}
240
243
  <div class="gap-1 flex flex-col">
241
- {#each filesArray as file (file.fileName + file.fileType)}
244
+ {#each filesArray as file, i (fullPath(file) + '#' + i)}
242
245
  <div
243
246
  class="gap-2 text-xs flex items-center justify-between text-muted-foreground"
244
247
  >
@@ -251,6 +254,24 @@
251
254
  </div>
252
255
  {/if}
253
256
  </div>
257
+ {#if hasDuplicates}
258
+ <div
259
+ class="rounded px-3 py-2 text-sm border border-destructive bg-destructive/10 text-destructive"
260
+ >
261
+ <div class="font-medium">Duplicate file names detected</div>
262
+ <div class="mt-1 text-xs">
263
+ The following {duplicatePaths.length === 1 ? 'path appears' : 'paths appear'} more than once:
264
+ </div>
265
+ <ul class="mt-1 ml-4 text-xs list-disc">
266
+ {#each duplicatePaths as path (path)}
267
+ <li class="truncate"><span class="font-mono">{path}</span></li>
268
+ {/each}
269
+ </ul>
270
+ <div class="mt-1 text-xs">
271
+ Rename outputs in your Grasshopper definition so each file has a unique name.
272
+ </div>
273
+ </div>
274
+ {/if}
254
275
  {#if downloadError}
255
276
  <div
256
277
  class="rounded px-3 py-2 text-sm border border-destructive bg-destructive/10 text-destructive"
@@ -258,7 +279,11 @@
258
279
  {downloadError}
259
280
  </div>
260
281
  {/if}
261
- <Button onclick={handleDownload} disabled={downloading} class="w-full">
282
+ <Button
283
+ onclick={handleDownload}
284
+ disabled={downloading || hasDuplicates}
285
+ class="w-full"
286
+ >
262
287
  {downloading
263
288
  ? 'Downloading...'
264
289
  : `Download ${fileCount === 1 ? 'File' : `${fileCount} Files`}`}
@@ -10,9 +10,26 @@
10
10
  }
11
11
 
12
12
  let { tabs, onTabChange }: Props = $props();
13
+
14
+ let viewportRef = $state<HTMLElement | null>(null);
15
+
16
+ function handleWheel(e: WheelEvent) {
17
+ if (!viewportRef) return;
18
+ // Trackpad horizontal swipes already produce deltaX — let those pass through.
19
+ if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return;
20
+ const canScroll = viewportRef.scrollWidth > viewportRef.clientWidth;
21
+ if (!canScroll) return;
22
+ e.preventDefault();
23
+ viewportRef.scrollLeft += e.deltaY;
24
+ }
13
25
  </script>
14
26
 
15
- <ScrollArea class="w-full shrink-0 border-b border-border" orientation="horizontal">
27
+ <ScrollArea
28
+ bind:viewportRef
29
+ class="w-full shrink-0 border-b border-border"
30
+ orientation="horizontal"
31
+ onwheel={handleWheel}
32
+ >
16
33
  <Tabs.List
17
34
  class="px-2 py-2 gap-0 inline-flex h-auto w-max justify-start rounded-none bg-transparent"
18
35
  >
@@ -16,12 +16,14 @@
16
16
  bind:ref
17
17
  data-slot="resizable-handle"
18
18
  class={cn(
19
- 'cn-resizable-handle after:inset-y-0 after:w-1 data-[direction=vertical]:after:left-0 data-[direction=vertical]:after:h-1 data-[direction=vertical]:after:translate-x-0 relative flex w-px items-center justify-center bg-border after:absolute after:left-1/2 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:outline-hidden data-[direction=vertical]:h-px data-[direction=vertical]:w-full data-[direction=vertical]:after:w-full data-[direction=vertical]:after:-translate-y-1/2 [&[data-direction=vertical]>div]:rotate-90',
19
+ 'cn-resizable-handle group/handle after:inset-y-0 after:w-1 data-[direction=vertical]:after:left-0 data-[direction=vertical]:after:h-1 data-[direction=vertical]:after:translate-x-0 relative flex w-px items-center justify-center bg-border after:absolute after:left-1/2 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:outline-hidden data-[direction=vertical]:h-px data-[direction=vertical]:w-full data-[direction=vertical]:after:w-full data-[direction=vertical]:after:-translate-y-1/2 [&[data-direction=vertical]>div]:rotate-90',
20
20
  className
21
21
  )}
22
22
  {...restProps}
23
23
  >
24
24
  {#if withHandle}
25
- <div class="h-6 w-1 z-10 flex shrink-0 rounded-lg bg-border"></div>
25
+ <div
26
+ class="h-10 w-1 z-10 flex shrink-0 rounded-lg bg-border transition-[background-color,transform] duration-150 group-hover/handle:bg-foreground/40 group-hover/handle:scale-y-110 group-active/handle:bg-foreground/60"
27
+ ></div>
26
28
  {/if}
27
29
  </ResizablePrimitive.PaneResizer>
@@ -0,0 +1,80 @@
1
+ // External-input transit storage.
2
+ //
3
+ // When an input has source.kind === 'external', a producer route writes the produced
4
+ // value here, and the solver route reads it back. Scoped per (scopeKey, inputId) so
5
+ // values for one solver/input don't bleed into another. The scope key is whatever
6
+ // uniquely identifies the solver context — sessionId in builder-app/preview,
7
+ // definition guid in compute-app/library, etc.
8
+ //
9
+ // inputId is the Grasshopper parameter instance GUID (LayoutItem.paramId / SchemaInput.id).
10
+
11
+ import type { UISchema, LayoutItem, GroupConfig, InputSource } from '@selvajs/schemas';
12
+
13
+ const STORAGE_PREFIX = 'external';
14
+
15
+ function makeKey(scopeKey: string, inputId: string): string {
16
+ return `${STORAGE_PREFIX}:${scopeKey}:${inputId}`;
17
+ }
18
+
19
+ export interface ExternalValueRef {
20
+ scopeKey: string;
21
+ inputId: string;
22
+ }
23
+
24
+ export function writeExternalValue(args: ExternalValueRef & { value: unknown }): void {
25
+ const { scopeKey, inputId, value } = args;
26
+ if (!scopeKey || !inputId) return;
27
+ if (typeof sessionStorage === 'undefined') return;
28
+ sessionStorage.setItem(makeKey(scopeKey, inputId), JSON.stringify(value));
29
+ }
30
+
31
+ export function readExternalValue(ref: ExternalValueRef): unknown | undefined {
32
+ const { scopeKey, inputId } = ref;
33
+ if (!scopeKey || !inputId) return undefined;
34
+ if (typeof sessionStorage === 'undefined') return undefined;
35
+ const raw = sessionStorage.getItem(makeKey(scopeKey, inputId));
36
+ if (raw === null) return undefined;
37
+ try {
38
+ return JSON.parse(raw);
39
+ } catch {
40
+ return undefined;
41
+ }
42
+ }
43
+
44
+ export function clearExternalValue(ref: ExternalValueRef): void {
45
+ const { scopeKey, inputId } = ref;
46
+ if (!scopeKey || !inputId) return;
47
+ if (typeof sessionStorage === 'undefined') return;
48
+ sessionStorage.removeItem(makeKey(scopeKey, inputId));
49
+ }
50
+
51
+ export interface ExternalInput {
52
+ paramId: string;
53
+ displayName: string;
54
+ }
55
+
56
+ function* walkLayoutItems(schema: UISchema): Generator<LayoutItem> {
57
+ const groups: GroupConfig[] =
58
+ schema.layout.type === 'tabbed'
59
+ ? schema.layout.tabs.flatMap((t) => t.groups)
60
+ : schema.layout.groups;
61
+ for (const group of groups) {
62
+ for (const item of group.items) {
63
+ yield item;
64
+ }
65
+ }
66
+ }
67
+
68
+ export function getExternalInputs(schema: UISchema): ExternalInput[] {
69
+ const result: ExternalInput[] = [];
70
+ for (const item of walkLayoutItems(schema)) {
71
+ if (item.type !== 'input') continue;
72
+ const source = (item as { source?: InputSource }).source;
73
+ if (source?.kind !== 'external') continue;
74
+ result.push({
75
+ paramId: item.paramId,
76
+ displayName: item.displayName ?? item.paramId
77
+ });
78
+ }
79
+ return result;
80
+ }
package/src/lib/index.ts CHANGED
@@ -19,6 +19,9 @@ export { default as Viewer } from './components/viewer/Viewer.svelte';
19
19
  export * from './schema/defaults';
20
20
  export * from './compute/solving.svelte';
21
21
 
22
+ // External-input transit storage (used by routes that wire pre-step producers)
23
+ export * from './external/storage';
24
+
22
25
  // Contexts & Composables
23
26
  export * from './contexts/footerContext.svelte';
24
27
  export * from './composables/useFooterItem.svelte';
@@ -72,6 +72,9 @@ export function evaluateVisibility(
72
72
  values: Record<string, unknown>
73
73
  ): VisibilityResult {
74
74
  if (item.type === 'linebreak') return { visible: true, disabled: false };
75
+ if ('visible' in item && item.visible === false) {
76
+ return { visible: false, disabled: false };
77
+ }
75
78
  if (!item.visibilityCondition?.rules) return { visible: true, disabled: false };
76
79
 
77
80
  const { action = 'show', defaultValue } = item.visibilityCondition;