@selvajs/ui 6.0.0-beta.8 → 6.1.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 (94) hide show
  1. package/README.md +5 -7
  2. package/dist/components/compute/AppLayout.svelte +3 -8
  3. package/dist/components/compute/AppLayout.svelte.d.ts +0 -2
  4. package/dist/components/compute/ComputeApp.svelte +30 -43
  5. package/dist/components/compute/ComputeApp.svelte.d.ts +11 -24
  6. package/dist/components/compute/ParameterPresetManager.svelte +4 -9
  7. package/dist/components/compute/ParameterPresetManager.svelte.d.ts +1 -1
  8. package/dist/components/layout/AppShell.svelte +5 -13
  9. package/dist/components/layout/AppShell.svelte.d.ts +1 -2
  10. package/dist/components/layout/EmptyState.svelte +2 -3
  11. package/dist/components/layout/PageFooter.svelte +0 -4
  12. package/dist/components/layout/index.js +0 -1
  13. package/dist/components/preview/Group.svelte +2 -5
  14. package/dist/components/preview/ImageOutput.svelte +5 -4
  15. package/dist/components/preview/InputControl.svelte +27 -28
  16. package/dist/components/preview/InputControl.svelte.d.ts +3 -3
  17. package/dist/components/preview/OutputDisplay.svelte +3 -6
  18. package/dist/components/preview/TabLayout.svelte +3 -3
  19. package/dist/components/preview/inputs/FileInput.svelte +9 -12
  20. package/dist/components/primitives/field/field-error.svelte +1 -3
  21. package/dist/components/primitives/index.js +0 -1
  22. package/dist/components/viewer/SceneManager.svelte +9 -20
  23. package/dist/components/viewer/SceneManager.svelte.d.ts +3 -3
  24. package/dist/components/viewer/Viewer.svelte +42 -63
  25. package/dist/components/viewer/Viewer.svelte.d.ts +8 -19
  26. package/dist/composables/useFooterItem.svelte.d.ts +3 -4
  27. package/dist/composables/useFooterItem.svelte.js +3 -3
  28. package/dist/compute/solving.svelte.d.ts +6 -6
  29. package/dist/compute/solving.svelte.js +6 -6
  30. package/dist/compute/useSolveSession.svelte.d.ts +5 -7
  31. package/dist/compute/useSolveSession.svelte.js +11 -24
  32. package/dist/constants.js +4 -4
  33. package/dist/contexts/clientSlotContext.svelte.d.ts +3 -5
  34. package/dist/contexts/footerContext.svelte.d.ts +6 -8
  35. package/dist/external/storage.js +2 -3
  36. package/dist/i18n/localeContext.svelte.d.ts +5 -7
  37. package/dist/i18n/localeContext.svelte.js +8 -15
  38. package/dist/i18n/messages.d.ts +0 -2
  39. package/dist/i18n/messages.js +3 -8
  40. package/dist/index.js +7 -13
  41. package/dist/public.js +14 -30
  42. package/dist/schema/defaults.js +2 -3
  43. package/dist/schema/dynamic-value-list.js +13 -15
  44. package/dist/schema/param-exporter.d.ts +7 -14
  45. package/dist/schema/param-exporter.js +6 -12
  46. package/dist/schema/traversal.js +3 -4
  47. package/dist/schema/visibility-rules.d.ts +4 -8
  48. package/dist/schema/visibility-rules.js +4 -8
  49. package/dist/types/actionButton.d.ts +1 -25
  50. package/dist/types/presetLabels.d.ts +2 -3
  51. package/dist/utils/color.js +6 -9
  52. package/dist/utils/debounce.d.ts +1 -11
  53. package/dist/utils/debounce.js +1 -11
  54. package/dist/utils/loadScript.d.ts +2 -7
  55. package/dist/utils/loadScript.js +2 -7
  56. package/package.json +17 -17
  57. package/src/lib/components/compute/AppLayout.svelte +3 -8
  58. package/src/lib/components/compute/ComputeApp.svelte +30 -43
  59. package/src/lib/components/compute/ParameterPresetManager.svelte +4 -9
  60. package/src/lib/components/layout/AppShell.svelte +5 -13
  61. package/src/lib/components/layout/EmptyState.svelte +2 -3
  62. package/src/lib/components/layout/PageFooter.svelte +0 -4
  63. package/src/lib/components/layout/index.ts +0 -1
  64. package/src/lib/components/preview/Group.svelte +2 -5
  65. package/src/lib/components/preview/ImageOutput.svelte +5 -4
  66. package/src/lib/components/preview/InputControl.svelte +27 -28
  67. package/src/lib/components/preview/OutputDisplay.svelte +3 -6
  68. package/src/lib/components/preview/TabLayout.svelte +3 -3
  69. package/src/lib/components/preview/inputs/FileInput.svelte +9 -12
  70. package/src/lib/components/primitives/field/field-error.svelte +1 -3
  71. package/src/lib/components/primitives/index.ts +0 -2
  72. package/src/lib/components/viewer/SceneManager.svelte +9 -20
  73. package/src/lib/components/viewer/Viewer.svelte +42 -63
  74. package/src/lib/composables/useFooterItem.svelte.ts +3 -4
  75. package/src/lib/compute/solving.svelte.ts +6 -6
  76. package/src/lib/compute/useSolveSession.svelte.ts +11 -24
  77. package/src/lib/constants.ts +4 -4
  78. package/src/lib/contexts/clientSlotContext.svelte.ts +7 -12
  79. package/src/lib/contexts/footerContext.svelte.ts +6 -8
  80. package/src/lib/external/storage.ts +2 -3
  81. package/src/lib/i18n/localeContext.svelte.ts +8 -17
  82. package/src/lib/i18n/messages.ts +3 -10
  83. package/src/lib/index.ts +8 -13
  84. package/src/lib/public.ts +22 -40
  85. package/src/lib/schema/defaults.ts +2 -3
  86. package/src/lib/schema/dynamic-value-list.ts +13 -15
  87. package/src/lib/schema/param-exporter.ts +9 -16
  88. package/src/lib/schema/traversal.ts +3 -4
  89. package/src/lib/schema/visibility-rules.ts +4 -8
  90. package/src/lib/types/actionButton.ts +1 -25
  91. package/src/lib/types/presetLabels.ts +2 -3
  92. package/src/lib/utils/color.ts +6 -9
  93. package/src/lib/utils/debounce.ts +1 -11
  94. package/src/lib/utils/loadScript.ts +2 -7
@@ -15,6 +15,7 @@
15
15
  import { hexToOklch } from '../../utils/color';
16
16
  import AppShell from '../layout/AppShell.svelte';
17
17
  import AppLayout from './AppLayout.svelte';
18
+ import { type ViewerConfig } from '../viewer/Viewer.svelte';
18
19
  import StateDisplay from '../primitives/StateDisplay.svelte';
19
20
  import { setClientSlot, type ClientSlot } from '../../contexts/clientSlotContext.svelte';
20
21
  import type { Locale } from '../../i18n/messages';
@@ -27,7 +28,7 @@
27
28
  onSolve: SolveFn;
28
29
  definitionKey?: string;
29
30
  title?: string;
30
- /** Branding logo URL shown as a watermark in the viewer's bottom-right corner. Hidden when unset. */
31
+ /** Watermark shown in the viewer's bottom-right corner. */
31
32
  logo?: string;
32
33
  isEmbedded?: boolean;
33
34
  primaryColor?: string;
@@ -41,55 +42,44 @@
41
42
  onListStates?: () => ParameterPreset[] | Promise<ParameterPreset[]>;
42
43
  /** Partial overrides for the preset-manager UI strings (e.g. for localization). */
43
44
  presetLabels?: Partial<PresetLabels>;
44
- /** Name shown in the footer copyright line. Defaults to the brand name ("Selva"). */
45
+ /** Name in the footer copyright line. Defaults to the brand name ("Selva"). */
45
46
  copyrightName?: string;
46
47
  /** Fully overrides the footer copyright line. `{name}` and `{year}` are substituted. */
47
48
  footerText?: string;
48
49
  /**
49
- * How long one solve may take before the client aborts it (ms). Required: pass
50
- * the same value the server enforces (`COMPUTE_SOLVE_DEADLINE_MS`), so the client
51
- * doesn't abort a solve that would have finished.
50
+ * How long one solve may take before the client aborts it (ms). Pass the same value the
51
+ * server enforces (`COMPUTE_SOLVE_DEADLINE_MS`), or the client aborts solves that would
52
+ * have finished.
52
53
  */
53
54
  solveDeadlineMs: number;
54
55
  footerComponent?: any;
55
56
  footerComponentProps?: () => Record<string, unknown>;
56
57
  footerItemId?: string;
57
58
  footerItemPriority?: number;
59
+ // `onReady` fires once, so the result and session are getters rather than snapshots.
58
60
  onReady?: (api: {
59
61
  loadValues: (values: Record<string, unknown>) => void;
60
- /**
61
- * The last result reported to the session — the one the viewer is showing, carrying
62
- * `source`/`values` even when a memo hit served it. Null before the first solve.
63
- * A getter, not a snapshot: `onReady` fires once.
64
- */
62
+ /** What the viewer is showing — carries `source`/`values` even on a memo hit. Null before the first solve. */
65
63
  getLastResult: () => RetainedSolveResult | null;
66
64
  /**
67
- * The live solve session, for hosts driving solves from their own state
68
- * `setValue`/`solve` to push inputs, `subscribe` to react to results.
69
- *
70
- * A getter for the same reason as `getLastResult`. Values written here go through the
65
+ * For hosts driving solves from their own state. Values written here go through the
71
66
  * same throttle and memo as the UI's, so it's safe to call at interaction rate.
72
67
  */
73
68
  getSession: () => SolveSession;
74
69
  }) => void;
75
- /**
76
- * Hands the live three.js viewer to the host once it mounts, for apps drawing their own
77
- * content alongside solve results. See `Viewer.svelte` for the contract.
78
- */
70
+ /** Hands the live three.js viewer to the host once it mounts. See `Viewer.svelte` for the contract. */
79
71
  onViewerReady?: (viewer: ThreeViewer) => void | (() => void);
72
+ /** Viewer chrome and defaults. `backgroundColor` and `showSceneManager` are set by the layout. */
73
+ viewerConfig?: ViewerConfig;
80
74
  headerRight?: Snippet;
81
75
  // Replaces the built-in header; takes precedence over `headerRight`.
82
76
  header?: Snippet;
83
77
  // Scopes sessionStorage for external-input values; falls back to definitionKey then schema.id.
84
78
  externalScopeKey?: string;
85
- // Renders client-sourced inputs with presentation === 'slot'; receives { inputId, displayName, value, onValueChange }.
79
+ // Renders client-sourced inputs with presentation === 'slot'.
86
80
  clientSlot?: ClientSlot;
87
- /**
88
- * UI language for the app's own chrome (viewer, panels, status text).
89
- * Provided once here and read by every descendant via locale context.
90
- * Defaults to English when unset. Does not translate schema-authored labels
91
- * or Grasshopper-sourced names/metadata.
92
- */
81
+ // Language for the app's own chrome, English when unset. Does not translate
82
+ // schema-authored labels or Grasshopper-sourced names/metadata.
93
83
  lang?: Locale;
94
84
  }
95
85
 
@@ -119,38 +109,36 @@
119
109
  header,
120
110
  onReady,
121
111
  onViewerReady,
112
+ viewerConfig = {},
122
113
  externalScopeKey,
123
114
  clientSlot,
124
115
  lang
125
116
  }: Props = $props();
126
117
 
127
- // Make the host's client-input slot available to InputControl deep in the tree.
118
+ // Reaches InputControl deep in the tree.
128
119
  // svelte-ignore state_referenced_locally
129
120
  setClientSlot(clientSlot);
130
121
 
131
- // Provide the UI locale once for the whole app subtree (viewer, panels, status
132
- // text). Resolution: explicit `lang` any host-provided locale → English. The
133
- // getter is re-read reactively, so switching `lang` updates the chrome live.
122
+ // Resolution: explicit `lang` host-provided locale English. Passed as a getter so
123
+ // switching `lang` updates the chrome live.
134
124
  const hostLocale = getLocaleContext();
135
125
  setLocaleContext(() => lang ?? hostLocale.locale);
136
126
  const t = $derived(getLocaleContext().messages);
137
127
 
138
128
  const resolvedScopeKey = $derived(externalScopeKey || definitionKey || schema?.id || '');
139
129
 
140
- // Solve Session owns the value/lifecycle state machine; the request/response driver
141
- // gives it its transport (Rhino.Compute over HTTP via onSolve, throttled). The driver
142
- // reads the reporter lazily so it can capture the session it's wired into.
130
+ // The session owns the value/lifecycle state machine; the driver is its transport. The
131
+ // session is passed as a getter because the two reference each other.
143
132
  // svelte-ignore state_referenced_locally
144
133
  const driver = createRequestResponseDriver(onSolve, () => session, {
145
134
  solveDeadlineMs,
146
- // The driver's result memo caches whole solve results, meshes included and the viewer
147
- // disposes what it renders on the next scene update. `@selvajs/solve` keeps meshes opaque,
148
- // so the three.js clone/dispose rules are injected from the renderer that owns them
149
- // (audit C1). Without this a memo hit serves an already-disposed mesh.
135
+ // The driver's memo caches whole solve results, meshes included, and the viewer disposes
136
+ // what it renders on the next scene update. `@selvajs/solve` keeps meshes opaque, so the
137
+ // three.js clone/dispose rules come from the renderer that owns them. Without this, a memo
138
+ // hit serves an already-disposed mesh.
150
139
  meshPolicy,
151
- // `session.isSolving` forwards to the driver, which the session can't observe on its
152
- // own — republish so the spinner and disabled states track it. Deferred into a
153
- // callback, so it reads `session` after initialization rather than during it.
140
+ // `isSolving` lives on the driver, which the session can't observe republish so the
141
+ // spinner and disabled states track it.
154
142
  onChange: () => session.notify()
155
143
  });
156
144
  // svelte-ignore state_referenced_locally
@@ -201,10 +189,8 @@
201
189
  session.solve();
202
190
  }
203
191
 
204
- // Read static props without creating reactive dependencies (registration is fixed at
205
- // mount). footerComponentProps is intentionally read live it's a getter the renderer
206
- // calls every render to keep the footer in sync. The composable no-ops when component
207
- // is absent, so the hook itself stays unconditional.
192
+ // Registration is fixed at mount, so the static props are untracked. `footerComponentProps`
193
+ // stays live the renderer calls it every render to keep the footer in sync.
208
194
  useFooterItem({
209
195
  id: untrack(() => footerItemId),
210
196
  component: untrack(() => footerComponent),
@@ -257,6 +243,7 @@
257
243
  values={session.values}
258
244
  logoUrl={logo}
259
245
  {onViewerReady}
246
+ {viewerConfig}
260
247
  {panelActions}
261
248
  {showSaveButton}
262
249
  {showLoadButton}
@@ -24,7 +24,7 @@
24
24
  onSaveState?: (state: ParameterPreset) => void | Promise<void>;
25
25
  /** When set, the Load dialog lists these states instead of showing a file input. */
26
26
  onListStates?: () => ParameterPreset[] | Promise<ParameterPreset[]>;
27
- /** Partial overrides for UI strings (e.g. for localization). */
27
+ /** Overrides merged over `DEFAULT_PRESET_LABELS`. */
28
28
  labels?: Partial<PresetLabels>;
29
29
  }
30
30
 
@@ -42,21 +42,18 @@
42
42
 
43
43
  const t = $derived({ ...DEFAULT_PRESET_LABELS, ...labels });
44
44
 
45
- // Save dialog state
46
45
  let showExportDialog = $state(false);
47
46
  let exportName = $state('');
48
47
  let exportDescription = $state('');
49
48
  let exportAuthor = $state('');
50
49
  let exportTags = $state('');
51
50
 
52
- // Import/validation state
53
51
  let showValidationDialog = $state(false);
54
52
  let showLoadDialog = $state(false);
55
53
  let loadResult = $state<PresetLoadResult | null>(null);
56
54
  let loadingPresetName = $state('');
57
55
  let fileInputRef = $state<HTMLInputElement | null>(null);
58
56
 
59
- // Listed states (when onListStates is provided)
60
57
  let listedStates = $state<ParameterPreset[]>([]);
61
58
  let isLoadingList = $state(false);
62
59
  let listError = $state('');
@@ -90,8 +87,8 @@
90
87
  showExportDialog = false;
91
88
  }
92
89
 
93
- // Load a preset in one pass, then either apply it directly (no issues) or open the
94
- // validation dialog (any errors or warnings). Shared by every load path.
90
+ // Shared by every load path: applies clean presets directly, routes any errors or
91
+ // warnings through the validation dialog.
95
92
  function tryLoad(preset: ParameterPreset) {
96
93
  const result = loadPreset(preset, schema);
97
94
  if (result.isValid) {
@@ -114,7 +111,7 @@
114
111
  alert(t.loadImportError + (error as Error).message);
115
112
  }
116
113
 
117
- // Reset input
114
+ // Reset so re-picking the same file fires `change` again.
118
115
  input.value = '';
119
116
  }
120
117
 
@@ -298,7 +295,6 @@
298
295
 
299
296
  {#if loadResult}
300
297
  <div class="gap-4 py-4 grid">
301
- <!-- Summary Alert -->
302
298
  {#if loadResult.isValid}
303
299
  <Card.Root class="p-4 border-success/30 bg-success/5">
304
300
  <div class="gap-3 flex items-start">
@@ -341,7 +337,6 @@
341
337
  </Card.Root>
342
338
  {/if}
343
339
 
344
- <!-- Issues List -->
345
340
  {#if !loadResult.isValid}
346
341
  <div class="space-y-2">
347
342
  <h4 class="text-sm font-medium">{t.validationIssuesHeading}</h4>
@@ -4,7 +4,6 @@
4
4
  import PageFooter from './PageFooter.svelte';
5
5
 
6
6
  interface AppShellProps {
7
- // Header
8
7
  showHeader?: boolean;
9
8
  homeUrl?: string;
10
9
  title?: string | null;
@@ -17,21 +16,19 @@
17
16
  subnav?: Snippet;
18
17
  headerClass?: string;
19
18
  /**
20
- * Bring-your-own header. When provided, this renders inside the sticky
21
- * header bar instead of the built-in PageHeader. The bar keeps the
19
+ * Replaces the built-in PageHeader inside the sticky header bar. The bar keeps the
22
20
  * standard `--header-h` height so the fixed-mode layout math is unaffected.
23
21
  */
24
22
  header?: Snippet;
25
23
 
26
- // Body layout
27
- // 'fixed' — full viewport, header + body + footer; body owns its own scroll. For app-like pages (builder, preview, library/[guid]).
28
- // 'scroll' — sticky header, body fills `100vh - --header-h` and scrolls. For dashboard-style pages.
24
+ // 'fixed' — viewport-locked, no page scroll; body owns its own scroll. For app-like
25
+ // pages (builder, preview, library/[guid]).
26
+ // 'scroll' — normal page flow, footer sticks to the bottom. For dashboard-style pages.
29
27
  mode?: 'fixed' | 'scroll';
30
28
 
31
- // Optional left rail. When present, body becomes flex row: sidenav | main.
29
+ // When present, body becomes a flex row: sidenav | main.
32
30
  sidenav?: Snippet;
33
31
 
34
- // Footer (only rendered when `showFooter`)
35
32
  showFooter?: boolean;
36
33
  errors?: string[];
37
34
  warnings?: string[];
@@ -39,7 +36,6 @@
39
36
  footerText?: string;
40
37
  footerChildren?: Snippet;
41
38
 
42
- // Body content
43
39
  class?: string;
44
40
  bodyClass?: string;
45
41
  children: Snippet;
@@ -70,12 +66,8 @@
70
66
  children
71
67
  }: AppShellProps = $props();
72
68
 
73
- // Default the footer's copyright owner to the header's brand name when the
74
- // caller didn't specify one.
75
69
  const _copyright = $derived(copyrightName ?? brandName);
76
70
 
77
- // fixed: viewport-locked, no page scroll — body owns its own scroll internally.
78
- // scroll: normal page flow, footer sticks to bottom via sticky positioning.
79
71
  const rootClass = $derived(
80
72
  _mode === 'fixed'
81
73
  ? `flex flex-col h-screen overflow-hidden bg-background ${className}`
@@ -2,16 +2,15 @@
2
2
  import type { Component, Snippet } from 'svelte';
3
3
 
4
4
  interface Props {
5
- // Optional icon component (e.g. lucide). Renders at h-8 w-8 above the title.
5
+ // Renders at h-8 w-8 above the title.
6
6
  icon?: Component<{ class?: string }>;
7
7
  title: string;
8
8
  description?: string;
9
9
  // Vertical breathing room. Most pages want `lg`; tighter contexts (drawer, nested card) use `sm`.
10
10
  size?: 'sm' | 'md' | 'lg';
11
11
  class?: string;
12
- // Free-form description (overrides plain `description`). Use when you need rich content like <code>.
12
+ // Overrides `description`. Use when the text needs rich content like <code>.
13
13
  body?: Snippet;
14
- // Buttons / links rendered below.
15
14
  actions?: Snippet;
16
15
  }
17
16
 
@@ -60,9 +60,7 @@
60
60
  <footer
61
61
  class="px-4 gap-4 text-xs h-7 flex shrink-0 items-center justify-between border-t border-border bg-background text-muted-foreground select-none"
62
62
  >
63
- <!-- Left section: Status items + custom context items -->
64
63
  <div class="gap-4 flex items-center">
65
- <!-- Custom left items from context -->
66
64
  {#each leftItems as item (item.id)}
67
65
  <FooterItemRenderer {item} />
68
66
  {/each}
@@ -103,9 +101,7 @@
103
101
  {/if}
104
102
  </div>
105
103
 
106
- <!-- Right section: Custom items + Copyright -->
107
104
  <div class="gap-4 ml-auto flex items-center">
108
- <!-- Custom right items from context -->
109
105
  {#each rightItems as item (item.id)}
110
106
  <FooterItemRenderer {item} />
111
107
  {/each}
@@ -1,4 +1,3 @@
1
- // Layout Components - Page structure and organization
2
1
  export { default as AppShell } from './AppShell.svelte';
3
2
  export { default as PageHeader } from './PageHeader.svelte';
4
3
  export { default as PageContent } from './PageContent.svelte';
@@ -47,11 +47,8 @@
47
47
  // One visibility evaluation per item per render; columnStarts and gridItem both read it.
48
48
  const visibilityMap = $derived(buildVisibilityMap(items, values));
49
49
 
50
- /**
51
- * Compute the column position (0-indexed) where each item starts, accounting
52
- * for spans and linebreak resets. Items hidden by visibility don't consume
53
- * a slot (matching what the renderer does — they're skipped entirely).
54
- */
50
+ // Hidden items must not consume a slot here, because gridItem skips them entirely —
51
+ // counting them would offset every following item's column.
55
52
  const columnStarts = $derived.by(() => {
56
53
  const positions: number[] = [];
57
54
  let col = 0;
@@ -21,10 +21,11 @@
21
21
  const ext = $derived(file ? (file.fileType ?? '').toLowerCase() : '');
22
22
  const isSupported = $derived(SUPPORTED_EXTS.has(ext));
23
23
 
24
- // Build a data URL. Binary formats (PNG/JPG/WEBP/GIF) come through as base64;
25
- // SVG arrives as plain UTF-8 XML and must be percent-encoded into the data URL.
26
- // Using <img src="..."> isolates SVG scripts (browsers disable scripting in
27
- // image-context SVGs), so untrusted SVG content cannot execute against the host.
24
+ // Binary formats (PNG/JPG/WEBP/GIF) arrive base64; SVG arrives as plain UTF-8 XML
25
+ // and must be percent-encoded into the data URL.
26
+ // Rendering through <img src> rather than inlining the SVG is a security choice:
27
+ // browsers disable scripting in image-context SVGs, so untrusted SVG cannot
28
+ // execute against the host. Don't switch to {@html} or an inline <svg>.
28
29
  const dataUrl = $derived.by(() => {
29
30
  if (!file || !isSupported) return null;
30
31
  const mime = MIME_BY_EXT[ext] ?? 'application/octet-stream';
@@ -34,9 +34,9 @@
34
34
  value?: SupportedTypes;
35
35
  displayName?: string;
36
36
  /**
37
- * Commit a value. `forceSolve` requests a solve even in manual-solve mode used for
38
- * system-initiated reconciliation (e.g. pruning a vanished dynamic-list selection) where
39
- * leaving the previous output on screen would misrepresent the now-changed input.
37
+ * `forceSolve` solves even in manual-solve mode. Used for system-initiated changes
38
+ * (e.g. pruning a vanished dynamic-list selection), where leaving the previous output
39
+ * on screen would misrepresent the input that now sits above it.
40
40
  */
41
41
  onChange: (paramId: string, value: SupportedTypes, forceSolve?: boolean) => void;
42
42
  disabled?: boolean;
@@ -56,9 +56,8 @@
56
56
  const inputId = $derived(`input-${item.paramId}`);
57
57
  const label = $derived(displayName || item.displayName || item.paramId);
58
58
 
59
- // Client-sourced input set to render a host element in its place. The 'hidden'
60
- // presentation never reaches here (visible:false filters it out upstream), so a
61
- // client slot source means presentation === 'slot'.
59
+ // The 'hidden' presentation never reaches here visible:false filters it out
60
+ // upstream so a client-sourced input here always means presentation === 'slot'.
62
61
  const isClientSlot = $derived.by(() => {
63
62
  const source = (item as { source?: { kind?: string; client?: { presentation?: string } } })
64
63
  .source;
@@ -66,7 +65,7 @@
66
65
  });
67
66
  const clientSlot = getClientSlot();
68
67
 
69
- // Number range hint shown next to label for sliders, under the input for plain number fields.
68
+ // Min/max hint rendered next to the label. `hideRange` lets the author suppress it.
70
69
  const numberRangeHint = $derived.by(() => {
71
70
  if (!isNumberWidget(item)) return null;
72
71
  const cfg = item.config;
@@ -81,8 +80,8 @@
81
80
 
82
81
  const showRangeInLabel = $derived(isNumberWidget(item) && numberRangeHint !== null);
83
82
 
84
- // Dynamic value list: computed options (from the last solve) take precedence over the
85
- // author's seed list. Empty until the first solve produces options, unless a default is set.
83
+ // Options from the last solve win over the author's seed list, so the control is empty
84
+ // until the first solve unless defaultOptions is set.
86
85
  const dynamicListConfig = $derived(
87
86
  isDynamicValueListWidget(item)
88
87
  ? (item.config as DynamicValueListWidgetConfig | undefined)
@@ -109,22 +108,22 @@
109
108
  (dynamicListConfig?.emptyBehavior ?? 'hide') === 'hide'
110
109
  );
111
110
 
112
- // When a dynamic value list recomputes, a previously-selected value may no longer be an
113
- // available option. Prune the stale selection so the control shows a valid option instead
114
- // of rendering the orphaned raw value as its own label. This is a system-initiated change
115
- // (the user didn't pick the new option), so force a solve otherwise manual-solve schemas
116
- // would keep the prior output on screen, making it look like the auto-picked option produced it.
111
+ // When a dynamic value list recomputes, the selected value may no longer be an option.
112
+ // Prune it so the control shows a valid option instead of rendering the orphaned raw
113
+ // value as its own label. The user didn't pick this, so force a solve — otherwise a
114
+ // manual-solve schema keeps the prior output on screen, making it look like the
115
+ // auto-picked option produced it.
117
116
  //
118
- // INVARIANT: a dynamic value list must never dispatch an empty/null value to solve. There
119
- // is always at least one option, and an empty selection reaches the definition as null/""
120
- // — which throws NREs in downstream geometry components (e.g. Bounding Rectangle) and nulls
121
- // every output beyond them. So every terminal state below resolves to a valid option; there
122
- // is deliberately no "user cleared it, stay empty" path.
117
+ // INVARIANT: a dynamic value list must never dispatch an empty/null value to solve. An
118
+ // empty selection reaches the definition as null/"", which throws NREs in downstream
119
+ // geometry components (e.g. Bounding Rectangle) and nulls every output beyond them.
120
+ // There is always at least one option, so every terminal state below resolves to one;
121
+ // there is deliberately no "user cleared it, stay empty" path.
123
122
  //
124
- // LOOP BREAKER: a definition whose computed options DEPEND on the selection can oscillate
125
- // auto-pick A → solve → new options exclude A → auto-pick B → solve → … Each cycle force-
126
- // solves and re-parses/re-renders the (possibly multi-MB) options, which FREEZES the tab
127
- // (each solve result blocks the main thread; the loop never yields). Bound consecutive
123
+ // LOOP BREAKER: a definition whose computed options DEPEND on the selection can oscillate
124
+ // auto-pick A → solve → new options exclude A → auto-pick B → solve → … Each cycle
125
+ // force-solves and re-parses the (possibly multi-MB) options, freezing the tab: every
126
+ // solve result blocks the main thread and the loop never yields. Bound consecutive
128
127
  // system-initiated picks; any real user commit resets the budget.
129
128
  const MAX_CONSECUTIVE_AUTO_PICKS = 3;
130
129
  let autoPickCount = 0;
@@ -133,11 +132,11 @@
133
132
  if (!isDynamicValueListWidget(item) || !dynamicListHasOptions) return;
134
133
  const validValues = new Set(Object.values(dynamicListOptions));
135
134
  const firstOption = Object.values(dynamicListOptions)[0];
136
- // Already valid → stable; refill the auto-pick budget and stop.
137
135
  const isValid = Array.isArray(value)
138
136
  ? value.length > 0 && value.every((v) => typeof v === 'string' && validValues.has(v))
139
137
  : typeof value === 'string' && value !== '' && validValues.has(value);
140
138
  if (isValid) {
139
+ // Settled on a valid option — refill the budget.
141
140
  autoPickCount = 0;
142
141
  return;
143
142
  }
@@ -151,21 +150,21 @@
151
150
  }
152
151
  if (Array.isArray(value)) {
153
152
  const pruned = value.filter((v) => typeof v === 'string' && validValues.has(v));
154
- // Empty (never selected or fully pruned) always falls back to the first option
155
- // a checklist that solves empty produces the same null cascade as a single value.
153
+ // Empty never selected or fully pruned falls back to the first option: a
154
+ // checklist that solves empty produces the same null cascade as a single value.
156
155
  if (pruned.length !== value.length || value.length === 0) {
157
156
  autoPickCount++;
158
157
  onChange(item.paramId, pruned.length > 0 ? pruned : [firstOption], true);
159
158
  }
160
159
  } else if (typeof value !== 'string' || value === '' || !validValues.has(value)) {
161
- // Never-selected or stale single value fall back to the first option.
160
+ // Same fallback for a single value: never-selected and stale both resolve, never clear.
162
161
  autoPickCount++;
163
162
  onChange(item.paramId, firstOption, true);
164
163
  }
165
164
  });
166
165
 
167
166
  function commit(newValue: SupportedTypes) {
168
- // A real user pick re-arms the system fallback (see autoPickCount above).
167
+ // A real user pick re-arms the auto-pick budget.
169
168
  autoPickCount = 0;
170
169
  value = newValue;
171
170
  onChange(item.paramId, newValue);
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { OutputLayoutItem } from '@selvajs/schemas';
3
- import type { FileData } from '@selvajs/compute/core';
3
+ import { subFolderSegments, type FileData } from '@selvajs/compute/core';
4
4
  import ChartOutput from './ChartOutput.svelte';
5
5
  import ImageOutput from './ImageOutput.svelte';
6
6
  import {
@@ -23,7 +23,6 @@
23
23
 
24
24
  let { item, value, displayName }: Props = $props();
25
25
 
26
- // --- text/number state ---
27
26
  let copied = $state(false);
28
27
  let copyTimeout: ReturnType<typeof setTimeout>;
29
28
 
@@ -47,7 +46,6 @@
47
46
  }
48
47
  }
49
48
 
50
- // --- file state ---
51
49
  let downloading = $state(false);
52
50
  let downloadError = $state<string | null>(null);
53
51
 
@@ -57,7 +55,6 @@
57
55
  const fileCount = $derived(filesArray.length);
58
56
  const totalSize = $derived(filesArray.reduce((sum, f) => sum + getBase64FileSize(f.data), 0));
59
57
 
60
- // --- folder tree ---
61
58
  type TreeNode =
62
59
  | { type: 'file'; file: FileData }
63
60
  | { type: 'folder'; name: string; children: SvelteMap<string, TreeNode> };
@@ -66,7 +63,7 @@
66
63
  const root = new SvelteMap<string, TreeNode>();
67
64
  for (let i = 0; i < files.length; i++) {
68
65
  const file = files[i];
69
- const parts = (file.subFolder || '').split('/').filter(Boolean);
66
+ const parts = subFolderSegments(file.subFolder);
70
67
  let current = root;
71
68
  for (const part of parts) {
72
69
  if (!current.has(part)) {
@@ -98,7 +95,7 @@
98
95
  const fileTree = $derived(hasSubFolders ? buildTree(filesArray) : null);
99
96
 
100
97
  function fullPath(f: FileData): string {
101
- const folder = (f.subFolder || '').replace(/^\/+|\/+$/g, '');
98
+ const folder = subFolderSegments(f.subFolder).join('/');
102
99
  const name = `${f.fileName}${f.fileType ?? ''}`;
103
100
  return folder ? `${folder}/${name}` : name;
104
101
  }
@@ -36,7 +36,6 @@
36
36
  // Computed value list options keyed by the target input id, derived from solved outputs.
37
37
  const dynamicOptions = $derived(buildDynamicValueListOptions(schema, values));
38
38
 
39
- // Tab selection
40
39
  $effect(() => {
41
40
  if (requestedTabId && visibleTabs.some((t) => t.id === requestedTabId)) {
42
41
  activeTabId = requestedTabId;
@@ -45,7 +44,7 @@
45
44
  }
46
45
  });
47
46
 
48
- // Collapsed group initialisation only sets groups that haven't been seen yet
47
+ // Seed each group's collapsed state once; re-seeding would discard the user's own toggles.
49
48
  $effect(() => {
50
49
  if (schema.layout.type !== 'tabbed') return;
51
50
  const initial: Record<string, boolean> = {};
@@ -57,7 +56,8 @@
57
56
  if (Object.keys(initial).length > 0) Object.assign(collapsedGroups, initial);
58
57
  });
59
58
 
60
- // Apply default values when visibility conditions hide or disable an item
59
+ // A hidden or disabled input still solves, so reset it to its default — otherwise the
60
+ // value the user last set while it was visible keeps feeding the definition.
61
61
  $effect(() => {
62
62
  if (schema.layout.type !== 'tabbed') return;
63
63
  const updates: Record<string, unknown> = {};
@@ -21,7 +21,6 @@
21
21
  onChange
22
22
  }: Props = $props();
23
23
 
24
- // Resolve which modes are actually available
25
24
  let effectiveModes = $derived(
26
25
  allowedInputModes && allowedInputModes.length > 0
27
26
  ? allowedInputModes
@@ -29,7 +28,6 @@
29
28
  );
30
29
  let showToggle = $derived(effectiveModes.length > 1);
31
30
 
32
- // Active mode: default to defaultInputMode if allowed, otherwise first allowed mode
33
31
  let activeMode = $state<'upload' | 'url'>('upload');
34
32
  $effect(() => {
35
33
  const preferred = defaultInputMode ?? 'upload';
@@ -44,7 +42,7 @@
44
42
  let urlError = $state<{ message: string; isCors: boolean; hint?: string } | null>(null);
45
43
  let urlSuccess = $state('');
46
44
 
47
- // Parse existing value if it's JSON
45
+ // The committed value is a JSON envelope; rebuild the display state from it.
48
46
  $effect(() => {
49
47
  if (value) {
50
48
  try {
@@ -79,8 +77,7 @@
79
77
  }
80
78
 
81
79
  function isCorsError(error: unknown): boolean {
82
- // CORS errors surface as a generic TypeError with no useful status code
83
- // because the browser blocks the response entirely
80
+ // The browser blocks the response entirely, so this is all we get — no status code.
84
81
  if (error instanceof TypeError && error.message === 'Failed to fetch') return true;
85
82
  return false;
86
83
  }
@@ -158,9 +155,8 @@
158
155
  return '';
159
156
  }
160
157
  })();
161
- // We can't distinguish CORS blocks from non-CORS network errors (404, DNS fail, etc.)
162
- // because the browser hides all of them behind the same opaque TypeError.
163
- // Give a message that covers both cases.
158
+ // The opaque TypeError hides CORS blocks and plain network failures (404, DNS)
159
+ // alike, so the message has to cover both.
164
160
  let hint =
165
161
  'Check that the URL is correct and the file is publicly accessible. If the server requires login, download the file and use the Upload option instead.';
166
162
  if (host.includes('sharepoint.com') || host.includes('onedrive.com')) {
@@ -201,10 +197,11 @@
201
197
  return;
202
198
  }
203
199
 
204
- // Guard the upload size client-side. The file is base64-embedded into the
205
- // compute request body, so an oversize file would otherwise be rejected
206
- // server-side with an opaque 413 (see COMPUTE_REQUEST_MAX_BYTES). The URL
207
- // import path has the same check; keep the two in sync.
200
+ // The file is base64-embedded into the compute request body, so without a
201
+ // client-side cap an oversize file comes back as an opaque 413. This cap is
202
+ // currently LOOSER than the server's COMPUTE_REQUEST_MAX_BYTES (see the TEMP
203
+ // note in constants.ts) some files pass here and still 413. The URL import
204
+ // path applies the same cap.
208
205
  if (file.size > APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_BYTES) {
209
206
  alert(
210
207
  `File too large: ${(file.size / 1024 / 1024).toFixed(2)}MB (max ${APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_MB}MB).`
@@ -15,13 +15,11 @@
15
15
  } = $props();
16
16
 
17
17
  const hasContent = $derived.by(() => {
18
- // has slotted error
19
18
  if (children) return true;
20
19
 
21
- // no errors
22
20
  if (!errors || errors.length === 0) return false;
23
21
 
24
- // has an error but no message
22
+ // A lone error with no message renders an empty alert box — suppress it.
25
23
  if (errors.length === 1 && !errors[0]?.message) {
26
24
  return false;
27
25
  }
@@ -1,5 +1,3 @@
1
- // UI Primitives - Reusable design system components
2
-
3
1
  // shadcn-svelte components
4
2
  export {
5
3
  Button,