@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
@@ -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);
@@ -4,9 +4,9 @@ interface Props {
4
4
  value?: SupportedTypes;
5
5
  displayName?: string;
6
6
  /**
7
- * Commit a value. `forceSolve` requests a solve even in manual-solve mode used for
8
- * system-initiated reconciliation (e.g. pruning a vanished dynamic-list selection) where
9
- * leaving the previous output on screen would misrepresent the now-changed input.
7
+ * `forceSolve` solves even in manual-solve mode. Used for system-initiated changes
8
+ * (e.g. pruning a vanished dynamic-list selection), where leaving the previous output
9
+ * on screen would misrepresent the input that now sits above it.
10
10
  */
11
11
  onChange: (paramId: string, value: SupportedTypes, forceSolve?: boolean) => void;
12
12
  disabled?: boolean;
@@ -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,4 +1,3 @@
1
- // UI Primitives - Reusable design system components
2
1
  // shadcn-svelte components
3
2
  export { Button, buttonVariants } from './button';
4
3
  export { Badge, badgeVariants } from './badge';
@@ -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" />
@@ -1,9 +1,9 @@
1
1
  import { type SceneOutliner } from '@selvajs/visualization/scene';
2
2
  interface Props {
3
3
  /**
4
- * Owned by `Viewer.svelte`, not built here: this panel unmounts when it is closed, and
5
- * hidden objects have to stay hidden — and keep being re-hidden after each solve — while it
6
- * is. Its state is backed by SvelteSets, so mutating one re-renders this list.
4
+ * Owned by `Viewer.svelte`, never built here: this panel unmounts when it closes, and hidden
5
+ * objects must stay hidden — and be re-hidden after each solve — while it is. Backed by
6
+ * SvelteSets, so mutating one re-renders this list.
7
7
  */
8
8
  outliner: SceneOutliner;
9
9
  sceneVersion?: number;