@selvajs/ui 6.0.0-beta.8 → 6.0.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 +2 -8
  3. package/dist/components/compute/AppLayout.svelte.d.ts +0 -2
  4. package/dist/components/compute/ComputeApp.svelte +25 -43
  5. package/dist/components/compute/ComputeApp.svelte.d.ts +8 -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 +36 -61
  25. package/dist/components/viewer/Viewer.svelte.d.ts +6 -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 +16 -16
  57. package/src/lib/components/compute/AppLayout.svelte +2 -8
  58. package/src/lib/components/compute/ComputeApp.svelte +25 -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 +36 -61
  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
@@ -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,
@@ -14,9 +14,9 @@
14
14
 
15
15
  interface Props {
16
16
  /**
17
- * Owned by `Viewer.svelte`, not built here: this panel unmounts when it is closed, and
18
- * hidden objects have to stay hidden — and keep being re-hidden after each solve — while it
19
- * is. Its state is backed by SvelteSets, so mutating one re-renders this list.
17
+ * Owned by `Viewer.svelte`, never built here: this panel unmounts when it closes, and hidden
18
+ * objects must stay hidden — and be re-hidden after each solve — while it is. Backed by
19
+ * SvelteSets, so mutating one re-renders this list.
20
20
  */
21
21
  outliner: SceneOutliner;
22
22
  sceneVersion?: number;
@@ -24,24 +24,20 @@
24
24
 
25
25
  let { outliner, sceneVersion = 0 }: Props = $props();
26
26
 
27
- // All list logic (content filtering, layer grouping, search, visibility, selection) lives in
28
- // @selvajs/visualization/scene. This component only renders it and forwards clicks.
29
27
  // Derived, not destructured: the prop is reassignable, and these must follow it.
30
28
  const hidden = $derived(outliner.visibility.hidden);
31
29
  const selected = $derived(outliner.selection.selected);
32
30
  const collapsed = $derived(outliner.collapsed);
33
31
 
34
- // Mirrored into runes because the outliner holds them as plain fields, not sets. Both start
35
- // fresh: the search box and the shift-anchor are panel state, so reopening the panel clears
36
- // them, while hiding and collapse persist in the outliner that outlives it.
32
+ // Mirrored into runes because the outliner holds these as plain fields, not sets. Both are panel
33
+ // state, so reopening clears them unlike hiding and collapse, which the outliner outlives.
37
34
  let searchQuery = $state('');
38
35
  let anchor = $state<string | null>(null);
39
36
 
40
37
  $effect(() => outliner.onAnchorChange((next) => (anchor = next)));
41
38
 
42
39
  // `scene.children` is a plain array the render layer mutates in place, so a solve bumping
43
- // `sceneVersion` is the only signal that content changed. Both derivations below read it, which
44
- // is what makes one walk per solve serve every template site that needs the list.
40
+ // `sceneVersion` is the only signal that content changed hence the `void` reads below.
45
41
  const sceneObjects = $derived.by(() => {
46
42
  void sceneVersion;
47
43
  return outliner.objects();
@@ -54,12 +50,9 @@
54
50
  return outliner.layerGroups();
55
51
  });
56
52
 
57
- // Hidden state is keyed by Grasshopper identity, not by uuid, so ask the outliner rather than
58
- // looking the object up in the set directly. Touching `hidden` registers the reactive read that
59
- // re-renders this row when the eye is clicked.
60
- // `SvelteSet.has()` is itself the reactive read, so go through the set rather than calling
61
- // `visibility.isHidden` — that reads the set through a plain reference inside the outliner and
62
- // would not re-render this row.
53
+ // `SvelteSet.has()` is the reactive read, so go through the set rather than calling
54
+ // `visibility.isHidden` that reaches the set through a plain reference inside the outliner
55
+ // and returns a correct value that never re-renders this row.
63
56
  const isObjectHidden = (object: THREE.Object3D) => hidden.has(getTrackingKey(object));
64
57
 
65
58
  // Same reason: count through the reactive set so the layer's tri-state eye tracks its objects.
@@ -165,7 +158,6 @@
165
158
  onkeydown={(e) =>
166
159
  e.key === 'Enter' && selectObject(object.uuid, e as unknown as MouseEvent)}
167
160
  >
168
- <!-- Object visibility toggle -->
169
161
  <button
170
162
  class="rounded p-1 shrink-0 transition-colors hover:bg-muted"
171
163
  onclick={(e) => {
@@ -182,7 +174,6 @@
182
174
  {/if}
183
175
  </button>
184
176
 
185
- <!-- Object name -->
186
177
  <span
187
178
  class="min-w-0 text-xs flex-1 truncate {isHidden
188
179
  ? 'text-muted-foreground line-through'
@@ -191,7 +182,6 @@
191
182
  {getObjectLabel(object)}
192
183
  </span>
193
184
 
194
- <!-- Type badge -->
195
185
  <span
196
186
  class="rounded px-1 py-0.5 font-medium shrink-0 bg-muted text-[9px] text-muted-foreground/70"
197
187
  >
@@ -203,7 +193,6 @@
203
193
  {/if}
204
194
  {/each}
205
195
 
206
- <!-- Empty state -->
207
196
  {#if sceneObjects.length === 0}
208
197
  <div class="py-12 flex flex-col items-center justify-center text-center">
209
198
  <EyeOff class="mb-2 h-5 w-5 text-muted-foreground/30" />
@@ -46,12 +46,7 @@
46
46
  showFullscreenButton?: boolean;
47
47
  showSceneManager?: boolean;
48
48
  showToolsMenu?: boolean;
49
- /** Expose the grid show/hide toggle in the tools menu. Grid starts hidden. */
50
49
  showGridToggle?: boolean;
51
- /**
52
- * Expose the "Display" submenu (render style picker + edges toggle) in the tools menu.
53
- * Defaults on. Starts on the 'technical' style with edges hidden.
54
- */
55
50
  showDisplayMenu?: boolean;
56
51
  enableMeshClick?: boolean;
57
52
  backgroundColor?: string;
@@ -64,26 +59,18 @@
64
59
  isBlurred?: boolean;
65
60
  drawerOpen?: boolean;
66
61
  viewerConfig?: ViewerConfig;
67
- /**
68
- * Branding logo URL. When set, shown as a small watermark in the viewer's
69
- * bottom-right corner. Omitted/empty renders nothing.
70
- */
62
+ /** Shown as a watermark in the bottom-right corner. */
71
63
  logoUrl?: string;
72
64
  /**
73
65
  * Hands the live three.js viewer to the host once the canvas is up, for apps drawing their
74
- * own content `addUserGeometry` (survives solves), `tools.register` (claim clicks before
75
- * selection), `labelLayer`, `cameraController`.
76
- *
77
- * Return a cleanup function to tear down what you added; it runs before the viewer disposes.
78
- * Anything added outside a solve needs `viewer.invalidate()` to repaint — the render loop is
79
- * on-demand.
66
+ * own content. Return a cleanup function to tear down what you added; it runs before the
67
+ * viewer disposes. Anything added outside a solve needs `viewer.invalidate()` to repaint —
68
+ * the render loop is on-demand.
80
69
  */
81
70
  onViewerReady?: (viewer: ThreeViewer) => void | (() => void);
82
71
  /**
83
- * UI language for the viewer's own chrome (tools menu, panels, dialogs).
84
- * When set, the viewer provides it to its subtree. When omitted, the viewer
85
- * reads the nearest locale context (set by the host app), defaulting to
86
- * English. Does not translate Grasshopper-sourced names/metadata.
72
+ * UI language for the viewer's own chrome. Omitted, the viewer reads the nearest locale
73
+ * context. Does not translate Grasshopper-sourced names/metadata.
87
74
  */
88
75
  lang?: Locale;
89
76
  }
@@ -113,13 +100,10 @@
113
100
 
114
101
  const config = $derived({ ...defaultViewerConfig, ...viewerConfig });
115
102
 
116
- // Read any host-provided locale before we (maybe) override it for our subtree.
103
+ // Read the host's locale before overriding it for our subtree.
117
104
  const hostLocale = getLocaleContext();
118
105
 
119
- // Resolution order: explicit `lang` prop host locale context → default.
120
- // Provide the resolved value to our subtree so the scene manager and metadata
121
- // dialog read the same locale. The getter is re-read reactively, so switching
122
- // the `lang` prop (or the host's locale) updates the chrome live.
106
+ // A getter, not a value: it is re-read reactively, so changing `lang` updates the chrome live.
123
107
  setLocaleContext(() => lang ?? hostLocale.locale);
124
108
  const locale = getLocaleContext();
125
109
  const t = $derived(locale.messages);
@@ -142,10 +126,8 @@
142
126
  let hideButton = $state(false);
143
127
  let sceneManagerOpen = $state(false);
144
128
 
145
- // The scene outliner lives here rather than inside <SceneManager> because that component is
146
- // mounted only while its panel is open. Hidden objects must stay hidden when the panel is
147
- // closed — and must be re-hidden after each solve, which nothing would do if the state
148
- // unmounted with the panel.
129
+ // The outliner lives here, not in <SceneManager>: that component mounts only while its panel is
130
+ // open, and hidden objects must stay hidden and be re-hidden after each solve — while it is closed.
149
131
  const hiddenObjects = new SvelteSet<string>();
150
132
  const selectedObjects = new SvelteSet<string>();
151
133
  const collapsedLayers = new SvelteSet<string>();
@@ -153,15 +135,12 @@
153
135
  let projection: CameraProjection = $state('perspective');
154
136
  let measureActive = $state(false);
155
137
  let gridVisible = $state(false);
156
- // Render style + edge overlays. 'technical' is the default look; edges (crease lines) start off —
157
- // both are user-switchable via the Display submenu.
158
138
  let renderStyle: Look = $state('technical');
159
139
  let edgesVisible = $state(false);
160
140
  let selectedMeshMetadata: Record<string, any> | null = $state(null);
161
141
  let selectedMeshName: string | null = $state(null);
162
142
 
163
- // Render-style options for the Display submenu, derived from the library's LOOKS array adding or
164
- // renaming a look in @selvajs/compute updates this automatically. Label is the value capitalized.
143
+ // Derived from LOOKS so adding a look in @selvajs/visualization shows up here with no edit.
165
144
  const STYLE_OPTIONS: { look: Look; label: string }[] = LOOKS.map((look) => ({
166
145
  look,
167
146
  label: look.charAt(0).toUpperCase() + look.slice(1)
@@ -192,16 +171,13 @@
192
171
  onMount(() => {
193
172
  if (!canvas) return;
194
173
 
195
- // Only options that differ from the library defaults. Seed the initial render style (also the
196
- // library default, but stated explicitly since it's user-switchable via the Display menu) and
197
- // switch off the sun/shadows the technical look doesn't need — flat ambient + HDR image-based
198
- // lighting (baseHDR loads by default) carry it. Grid/measure/click are the tools this viewer uses.
174
+ // Only what differs from the library defaults. The sun and shadows are off because the
175
+ // technical look doesn't need them flat ambient plus the HDR environment carry it.
199
176
  const opts: ThreeInitializerOptions = {
200
177
  look: renderStyle,
201
178
  lighting: { enableSunlight: false },
202
179
  render: { enableShadows: false },
203
180
  environment: { backgroundColor: config.backgroundColor },
204
- // Build the grid so it can be toggled at runtime, but start hidden (off by default).
205
181
  grid: { enabled: config.showToolsMenu && config.showGridToggle },
206
182
  measure: { enabled: config.showToolsMenu },
207
183
  events: {
@@ -237,8 +213,7 @@
237
213
 
238
214
  const renderer = init.renderer;
239
215
 
240
- // Untracked: the host's setup runs once against this canvas, and reading `meshes` or config
241
- // inside it must not re-run onMount's teardown.
216
+ // Untracked so the host reading `meshes` or config inside its setup can't re-run onMount's teardown.
242
217
  const hostCleanup = untrack(() => onViewerReady?.(init));
243
218
 
244
219
  return () => {
@@ -280,9 +255,8 @@
280
255
  setLook(look);
281
256
  }
282
257
 
283
- // Add/remove crease-edge overlays on the current scene content. applyEdges is idempotent per mesh
284
- // (and attaches large meshes' overlays async, off the main thread); clearEdges is its inverse
285
- // it also cancels in-flight attaches and stands down the screen-space fallback for capped meshes.
258
+ // `applyEdges` is idempotent per mesh, so the repeated calls after each solve add no duplicate
259
+ // overlays; `clearEdges` is its inverse.
286
260
  function applyEdgeState() {
287
261
  if (!scene) return;
288
262
  if (edgesVisible) applyEdges?.(scene);
@@ -297,18 +271,18 @@
297
271
  $effect(() => {
298
272
  if (scene && camera && controls) {
299
273
  updateScene(scene, meshes, camera, controls, viewerInitialized);
300
- // updateScene clears and re-adds all content each solve, so the previous solve's edge
301
- // overlays are gone re-attach them if edges are currently shown. Read the flag untracked:
302
- // toggling edges is handled directly by toggleEdges(), so it must not re-trigger a full solve.
274
+ // Untracked because toggleEdges() already handles the toggle directly reading
275
+ // `edgesVisible` tracked here would re-trigger a full solve.
303
276
  untrack(() => {
277
+ // updateScene discarded the previous solve's overlays along with its content.
304
278
  if (edgesVisible) applyEdges?.(scene!);
305
- // Rescale the grid to the new content's extent so cells and fade match the part size.
279
+ // Rescale the grid so cells and fade match the new content's extent.
306
280
  updateGridScale?.();
307
- // The rebuild above also un-hid everything the user had hidden. Re-hide it: the outliner
308
- // keys that state on Grasshopper identity, not on the instances just discarded.
281
+ // The rebuild un-hid everything; the outliner keys hidden state on Grasshopper
282
+ // identity, not on the instances just discarded, so it can re-hide it.
309
283
  outliner?.applyTo();
310
284
  sceneVersion++;
311
- // New solve content — repaint now rather than on the render loop's safety interval.
285
+ // Repaint now rather than on the render loop's safety interval.
312
286
  invalidate?.();
313
287
  });
314
288
 
@@ -373,11 +347,19 @@
373
347
  <!-- `defaultSize` must sum to 100 across the live panes. Panes register a frame before the
374
348
  group recomputes its layout, so a sum of 115 renders one frame at the raw flex-grow ratio
375
349
  and is then renormalized — and if the recompute short-circuits on an equal layout, the
376
- scene pane keeps a sliver of its intended width. Hence 85 + 15, and the explicit
377
- id/order so a conditionally-rendered pane keeps its slot. -->
350
+ scene pane keeps a sliver of its intended width. Hence 85 + 15, and the explicit id/order
351
+ so a conditionally-rendered pane keeps its slot. -->
378
352
  <Resizable.Pane id="viewport" order={1} defaultSize={sceneManagerOpen ? 85 : 100} minSize={40}>
379
353
  <div class="relative h-full w-full" style="touch-action: none;">
380
- <canvas class="block h-full w-full" bind:this={canvas}></canvas>
354
+ <!-- Mesh count of the geometry currently in the scene. The canvas itself is opaque to
355
+ the DOM, so this is the only observable proof a solve's geometry decoded and
356
+ rendered; e2e asserts on it. -->
357
+ <canvas
358
+ class="block h-full w-full"
359
+ data-testid="viewer-canvas"
360
+ data-mesh-count={meshes.length}
361
+ bind:this={canvas}
362
+ ></canvas>
381
363
 
382
364
  <div
383
365
  class="inset-0 blur-overlay absolute z-20 {isBlurred
@@ -392,9 +374,8 @@
392
374
  {/if}
393
375
 
394
376
  {#if logoUrl}
395
- <!-- Branding watermark, bottom-right. Matches the tools menu's
396
- bottom offset so it clears the mobile drawer handle, and is
397
- non-interactive so it never intercepts canvas drags. -->
377
+ <!-- Bottom offset matches the tools menu so it clears the mobile drawer handle.
378
+ Always pointer-events:none so it never intercepts canvas drags. -->
398
379
  <div
399
380
  class="right-4 {isFullscreen ? 'bottom-4' : 'bottom-16 sm:bottom-4'} absolute z-20"
400
381
  style={hideButton
@@ -434,7 +415,6 @@
434
415
  align="start"
435
416
  class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 min-w-44 p-1 shadow-md z-10001 rounded-md border bg-popover text-popover-foreground"
436
417
  >
437
- <!-- Camera -->
438
418
  <DropdownMenu.Item class={itemClass} onSelect={toggleProjection}>
439
419
  {#if projection === 'perspective'}
440
420
  <Square class="h-4 w-4" />
@@ -482,7 +462,6 @@
482
462
  sideOffset={4}
483
463
  class="min-w-40 p-1 shadow-md z-10001 rounded-md border bg-popover text-popover-foreground"
484
464
  >
485
- <!-- Render style: single-choice, current one checked. -->
486
465
  {#each STYLE_OPTIONS as { look, label } (look)}
487
466
  <DropdownMenu.Item
488
467
  closeOnSelect={false}
@@ -498,7 +477,6 @@
498
477
 
499
478
  <DropdownMenu.Separator class="my-1 h-px bg-border" />
500
479
 
501
- <!-- Edges overlay toggle. -->
502
480
  <DropdownMenu.Item
503
481
  closeOnSelect={false}
504
482
  class="{itemClass} {edgesVisible ? 'text-primary' : ''}"
@@ -540,7 +518,6 @@
540
518
  </DropdownMenu.Item>
541
519
  {/if}
542
520
 
543
- <!-- Scene tools -->
544
521
  {#if config.showSceneManager || config.showScreenshotButton || config.showFullscreenButton}
545
522
  <DropdownMenu.Separator class="my-1 h-px bg-border" />
546
523
  {/if}
@@ -585,7 +562,6 @@
585
562
  </div>
586
563
  </Resizable.Pane>
587
564
 
588
- <!-- Scene Manager Pane -->
589
565
  {#if sceneManagerOpen && scene && outliner}
590
566
  <Resizable.Handle withHandle />
591
567
  <Resizable.Pane id="scene-manager" order={2} defaultSize={15} minSize={8} maxSize={30}>
@@ -618,7 +594,6 @@
618
594
  border-radius: 0 !important;
619
595
  }
620
596
 
621
- /* Blur overlay animation */
622
597
  .blur-overlay {
623
598
  pointer-events: none;
624
599
  }
@@ -5,7 +5,6 @@ import {
5
5
  FOOTER_CONTEXT_KEY
6
6
  } from '$lib/contexts/footerContext.svelte';
7
7
 
8
- /** Config for a footer-item registration; generic over the component's props. */
9
8
  export type FooterItemConfig<P extends Record<string, unknown> = Record<string, unknown>> = Omit<
10
9
  FooterItem<P>,
11
10
  'position' | 'priority'
@@ -15,9 +14,9 @@ export type FooterItemConfig<P extends Record<string, unknown> = Record<string,
15
14
  /**
16
15
  * Register a footer item for the lifetime of the calling component.
17
16
  *
18
- * Registration is deferred to onMount (client-side only) to avoid SSR context errors;
19
- * it's unregistered on destroy. A no-op when no footer context is present (e.g. a
20
- * component rendered outside the root layout) or no component is supplied.
17
+ * Registration waits for onMount so it never runs during SSR, where reading the context
18
+ * throws. A no-op when no footer context is present (a component rendered outside the root
19
+ * layout) or no component is supplied.
21
20
  */
22
21
  export function useFooterItem<P extends Record<string, unknown>>(config: FooterItemConfig<P>) {
23
22
  onMount(() => {