@selvajs/ui 6.6.0-beta.4 → 6.6.0-beta.5

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 (111) hide show
  1. package/dist/components/ErrorScreen.svelte +6 -2
  2. package/dist/components/compute/AppLayout.svelte +157 -71
  3. package/dist/components/compute/AppLayout.svelte.d.ts +6 -0
  4. package/dist/components/compute/ComputeApp.svelte +39 -20
  5. package/dist/components/compute/ComputeApp.svelte.d.ts +19 -2
  6. package/dist/components/compute/ComputeMessagesDialog.svelte +30 -40
  7. package/dist/components/compute/ParameterPresetManager.svelte +7 -44
  8. package/dist/components/compute/ParameterPresetManager.svelte.d.ts +4 -5
  9. package/dist/components/compute/SolveMessages.svelte +30 -37
  10. package/dist/components/compute/SolveMessages.svelte.d.ts +0 -12
  11. package/dist/components/layout/PageFooter.svelte +21 -11
  12. package/dist/components/preview/ChartOutput.svelte +12 -7
  13. package/dist/components/preview/Group.svelte +33 -18
  14. package/dist/components/preview/HelpButton.svelte +4 -2
  15. package/dist/components/preview/ImageOutput.svelte +427 -68
  16. package/dist/components/preview/InputControl.svelte +11 -2
  17. package/dist/components/preview/OutputDisplay.svelte +17 -12
  18. package/dist/components/preview/TabContent.svelte +3 -0
  19. package/dist/components/preview/inputs/ColorInput.svelte +3 -1
  20. package/dist/components/preview/inputs/ColorInput.svelte.d.ts +1 -0
  21. package/dist/components/preview/inputs/DropdownInput.svelte +4 -1
  22. package/dist/components/preview/inputs/FileInput.svelte +50 -34
  23. package/dist/components/preview/inputs/NumberInput.svelte +92 -10
  24. package/dist/components/preview/inputs/NumberInput.svelte.d.ts +2 -0
  25. package/dist/components/preview/inputs/TextInput.svelte +8 -3
  26. package/dist/components/primitives/CalculateButton.svelte +49 -23
  27. package/dist/components/primitives/CalculateButton.svelte.d.ts +3 -0
  28. package/dist/components/primitives/SolvingIndicator.svelte +21 -17
  29. package/dist/components/primitives/badge/badge.svelte +2 -2
  30. package/dist/components/primitives/badge/badge.svelte.d.ts +4 -4
  31. package/dist/components/primitives/button/button.svelte +2 -2
  32. package/dist/components/primitives/button/button.svelte.d.ts +4 -4
  33. package/dist/components/primitives/checkbox/checkbox.svelte +1 -1
  34. package/dist/components/primitives/dialog/dialog-content.svelte +6 -2
  35. package/dist/components/primitives/input/input.svelte +2 -2
  36. package/dist/components/primitives/input-group/input-group.svelte +1 -1
  37. package/dist/components/primitives/mode-toggle/mode-toggle.svelte +4 -1
  38. package/dist/components/primitives/scroll-area/scroll-area.svelte +1 -1
  39. package/dist/components/primitives/search/search.svelte +1 -1
  40. package/dist/components/primitives/select/select-trigger.svelte +1 -1
  41. package/dist/components/primitives/slider/slider.svelte +7 -2
  42. package/dist/components/primitives/slider/slider.svelte.d.ts +5 -1
  43. package/dist/components/primitives/switch/switch.svelte +1 -1
  44. package/dist/components/primitives/tabs/tabs-trigger.svelte +1 -1
  45. package/dist/components/primitives/tabs/tabs-trigger.svelte.d.ts +1 -1
  46. package/dist/components/primitives/textarea/textarea.svelte +1 -1
  47. package/dist/components/primitives/toggle/toggle.svelte +1 -1
  48. package/dist/components/primitives/toggle/toggle.svelte.d.ts +1 -1
  49. package/dist/components/viewer/Viewer.svelte +20 -17
  50. package/dist/compute/run-panel-action.d.ts +8 -0
  51. package/dist/compute/run-panel-action.js +12 -0
  52. package/dist/compute/useSolveSession.svelte.js +3 -0
  53. package/dist/i18n/localeContext.svelte.d.ts +10 -3
  54. package/dist/i18n/localeContext.svelte.js +27 -5
  55. package/dist/i18n/messages.d.ts +115 -2
  56. package/dist/i18n/messages.js +199 -11
  57. package/dist/index.d.ts +1 -1
  58. package/dist/public.d.ts +1 -1
  59. package/dist/schema/visibility-rules.js +21 -0
  60. package/dist/styles/base.css +9 -6
  61. package/dist/types/actionButton.d.ts +17 -3
  62. package/dist/types/presetLabels.d.ts +3 -2
  63. package/dist/types/presetLabels.js +9 -8
  64. package/package.json +4 -4
  65. package/src/lib/components/ErrorScreen.svelte +6 -2
  66. package/src/lib/components/compute/AppLayout.svelte +157 -71
  67. package/src/lib/components/compute/ComputeApp.svelte +39 -20
  68. package/src/lib/components/compute/ComputeMessagesDialog.svelte +30 -40
  69. package/src/lib/components/compute/ParameterPresetManager.svelte +7 -44
  70. package/src/lib/components/compute/SolveMessages.svelte +30 -37
  71. package/src/lib/components/layout/PageFooter.svelte +21 -11
  72. package/src/lib/components/preview/ChartOutput.svelte +12 -7
  73. package/src/lib/components/preview/Group.svelte +33 -18
  74. package/src/lib/components/preview/HelpButton.svelte +4 -2
  75. package/src/lib/components/preview/ImageOutput.svelte +427 -68
  76. package/src/lib/components/preview/InputControl.svelte +11 -2
  77. package/src/lib/components/preview/OutputDisplay.svelte +17 -12
  78. package/src/lib/components/preview/TabContent.svelte +3 -0
  79. package/src/lib/components/preview/inputs/ColorInput.svelte +3 -1
  80. package/src/lib/components/preview/inputs/DropdownInput.svelte +4 -1
  81. package/src/lib/components/preview/inputs/FileInput.svelte +50 -34
  82. package/src/lib/components/preview/inputs/NumberInput.svelte +92 -10
  83. package/src/lib/components/preview/inputs/TextInput.svelte +8 -3
  84. package/src/lib/components/primitives/CalculateButton.svelte +49 -23
  85. package/src/lib/components/primitives/SolvingIndicator.svelte +21 -17
  86. package/src/lib/components/primitives/badge/badge.svelte +2 -2
  87. package/src/lib/components/primitives/button/button.svelte +2 -2
  88. package/src/lib/components/primitives/checkbox/checkbox.svelte +1 -1
  89. package/src/lib/components/primitives/dialog/dialog-content.svelte +6 -2
  90. package/src/lib/components/primitives/input/input.svelte +2 -2
  91. package/src/lib/components/primitives/input-group/input-group.svelte +1 -1
  92. package/src/lib/components/primitives/mode-toggle/mode-toggle.svelte +4 -1
  93. package/src/lib/components/primitives/scroll-area/scroll-area.svelte +1 -1
  94. package/src/lib/components/primitives/search/search.svelte +1 -1
  95. package/src/lib/components/primitives/select/select-trigger.svelte +1 -1
  96. package/src/lib/components/primitives/slider/slider.svelte +7 -2
  97. package/src/lib/components/primitives/switch/switch.svelte +1 -1
  98. package/src/lib/components/primitives/tabs/tabs-trigger.svelte +1 -1
  99. package/src/lib/components/primitives/textarea/textarea.svelte +1 -1
  100. package/src/lib/components/primitives/toggle/toggle.svelte +1 -1
  101. package/src/lib/components/viewer/Viewer.svelte +20 -17
  102. package/src/lib/compute/run-panel-action.ts +16 -0
  103. package/src/lib/compute/useSolveSession.svelte.ts +3 -0
  104. package/src/lib/i18n/localeContext.svelte.ts +40 -5
  105. package/src/lib/i18n/messages.ts +351 -13
  106. package/src/lib/index.ts +1 -0
  107. package/src/lib/public.ts +1 -0
  108. package/src/lib/schema/visibility-rules.ts +23 -0
  109. package/src/lib/styles/base.css +9 -6
  110. package/src/lib/types/actionButton.ts +16 -3
  111. package/src/lib/types/presetLabels.ts +13 -11
@@ -1,8 +1,9 @@
1
1
  <script lang="ts">
2
2
  import type { UISchema, SupportedTypes, ParameterPreset } from '@selvajs/schemas';
3
+ import type { SolveSettlement } from '@selvajs/solve/client';
3
4
  import type { ActionButton } from '../../types/actionButton';
4
5
  import type { PresetLabels } from '../../types/presetLabels';
5
- import { ChevronUp } from '@lucide/svelte';
6
+ import { ChevronUp, Download, Ellipsis, Upload } from '@lucide/svelte';
6
7
  import type { ThreeViewer } from '@selvajs/visualization/render';
7
8
  import Viewer, { type ViewerConfig } from '../viewer/Viewer.svelte';
8
9
  import CalculateButton from '../primitives/CalculateButton.svelte';
@@ -10,10 +11,13 @@
10
11
  import TabLayout from '../preview/TabLayout.svelte';
11
12
  import CollapsedPanelStrip from './CollapsedPanelStrip.svelte';
12
13
  import * as Resizable from '$lib/components/primitives/resizable';
14
+ import * as DropdownMenu from '$lib/components/primitives/dropdown-menu/index.js';
13
15
  import { Button } from '$lib';
14
16
  import ParameterPresetManager from './ParameterPresetManager.svelte';
17
+ import { runPanelAction } from '../../compute/run-panel-action';
15
18
  import { getLocaleContext } from '../../i18n/localeContext.svelte';
16
19
  import { buildVisibilityMap, isTabVisible, itemKey } from '../../schema/visibility-rules';
20
+ import { DEFAULT_PRESET_LABELS } from '../../types/presetLabels';
17
21
 
18
22
  const locale = getLocaleContext();
19
23
  const t = $derived(locale.messages);
@@ -29,6 +33,11 @@
29
33
  hasNeverSolved?: boolean;
30
34
  isViewerFullscreen?: boolean;
31
35
  oncalculate?: () => void;
36
+ /**
37
+ * The result for the current inputs, solving if it must (`session.ensureSolved`). Needed
38
+ * by `solvesFirst` actions; without it they run straight away.
39
+ */
40
+ ensureSolved?: () => Promise<SolveSettlement>;
32
41
  values: Record<string, unknown>;
33
42
  onValueChange: (id: string, val: SupportedTypes, forceSolve?: boolean) => void | Promise<void>;
34
43
  onLoadValues?: (values: Record<string, unknown>) => void | Promise<void>;
@@ -52,6 +61,7 @@
52
61
  hasNeverSolved = false,
53
62
  isViewerFullscreen = $bindable(false),
54
63
  oncalculate = () => {},
64
+ ensureSolved,
55
65
  values,
56
66
  onValueChange,
57
67
  onLoadValues,
@@ -66,6 +76,11 @@
66
76
  onViewerReady
67
77
  }: Props = $props();
68
78
 
79
+ const presetText = $derived({ ...DEFAULT_PRESET_LABELS, ...presetLabels });
80
+ let presets = $state<ParameterPresetManager | null>(null);
81
+ const hasMenu = $derived(showSaveButton || showLoadButton || panelActions.length > 0);
82
+ const resultIsStale = $derived(hasPendingChanges || hasNeverSolved);
83
+
69
84
  const hasViewer = $derived(
70
85
  !!(schema?.viewerOptions?.enableLocal || schema?.viewerOptions?.enableRemote)
71
86
  );
@@ -151,54 +166,110 @@
151
166
  }
152
167
  </script>
153
168
 
169
+ <!-- Everything but Calculate: saving and loading inputs, then the host's own actions. -->
170
+ {#snippet actionsMenu()}
171
+ <DropdownMenu.Root>
172
+ <DropdownMenu.Trigger>
173
+ {#snippet child({ props })}
174
+ <Button
175
+ {...props}
176
+ variant="outline"
177
+ size="icon-lg"
178
+ class="shrink-0"
179
+ title={t.moreActions}
180
+ aria-label={t.moreActions}
181
+ >
182
+ <Ellipsis />
183
+ </Button>
184
+ {/snippet}
185
+ </DropdownMenu.Trigger>
186
+ <!-- Above the fullscreen viewer, which sits at z-index 10000. -->
187
+ <DropdownMenu.Content side="top" align="end" sideOffset={6} class="min-w-56 z-10001 w-auto">
188
+ {#if showSaveButton}
189
+ <DropdownMenu.Item onSelect={() => presets?.openSave()}>
190
+ <Download />
191
+ {presetText.saveMenuItem}
192
+ </DropdownMenu.Item>
193
+ {/if}
194
+ {#if showLoadButton}
195
+ <DropdownMenu.Item onSelect={() => presets?.openLoad()}>
196
+ <Upload />
197
+ {presetText.loadMenuItem}
198
+ </DropdownMenu.Item>
199
+ {/if}
200
+ {#if (showSaveButton || showLoadButton) && panelActions.length > 0}
201
+ <DropdownMenu.Separator />
202
+ {/if}
203
+ {#each panelActions as action (action.id)}
204
+ {@const hint =
205
+ action.hint ?? (action.solvesFirst && resultIsStale ? t.calculatesFirst : undefined)}
206
+ <DropdownMenu.Item
207
+ disabled={action.disabled}
208
+ variant={action.variant ?? 'default'}
209
+ onSelect={() => runPanelAction(action, ensureSolved)}
210
+ class="items-start"
211
+ >
212
+ {#if action.icon}
213
+ {@const IconComponent = action.icon}
214
+ <IconComponent class="mt-0.5" />
215
+ {/if}
216
+ <span class="flex flex-col">
217
+ {action.label}
218
+ {#if hint}
219
+ <span class="text-xs leading-snug text-muted-foreground">{hint}</span>
220
+ {/if}
221
+ </span>
222
+ </DropdownMenu.Item>
223
+ {/each}
224
+ </DropdownMenu.Content>
225
+ </DropdownMenu.Root>
226
+ {/snippet}
227
+
228
+ <!-- One primary action and one menu. A definition that solves on every change has no Calculate,
229
+ so the menu sits alone at the end of the row. -->
230
+ {#snippet footer()}
231
+ {#if schema.instanceSolve === false}
232
+ <CalculateButton
233
+ {hasPendingChanges}
234
+ {hasNeverSolved}
235
+ {isSolving}
236
+ {oncalculate}
237
+ trailing={hasMenu ? actionsMenu : undefined}
238
+ />
239
+ {:else if hasMenu}
240
+ <div class="mt-3 pb-3 pt-2 px-2 flex justify-end border-t border-border/50">
241
+ {@render actionsMenu()}
242
+ </div>
243
+ {/if}
244
+ {/snippet}
245
+
246
+ <!-- `hostsFooter` marks the one panel that carries the footer. On a phone the footer moves to the
247
+ drawer's pinned bottom instead. -->
154
248
  {#snippet panelContent(
155
249
  panelFilter: 'left' | 'right' | undefined,
156
250
  requestedTabId: string | null,
157
- showParameterStateManager = true,
158
- showCalculateButton = true
251
+ hostsFooter = true
159
252
  )}
160
253
  <div class="panel-content-wrapper">
161
254
  {#if schema.layout.type === 'tabbed'}
162
255
  <TabLayout {schema} {values} {onValueChange} {panelFilter} {requestedTabId} />
163
256
  {/if}
164
- {#if showParameterStateManager || (!isMobile && showCalculateButton && schema.instanceSolve === false)}
257
+ {#if hostsFooter && !isMobile}
165
258
  <div class="panel-footer px-1">
166
- <div class="gap-2 flex flex-wrap items-center [&>button]:flex-1">
167
- {#if showParameterStateManager}
168
- <ParameterPresetManager
169
- {schema}
170
- currentValues={values}
171
- onLoadValues={handleLoadValues}
172
- {showSaveButton}
173
- {showLoadButton}
174
- {onSaveState}
175
- {onListStates}
176
- labels={presetLabels}
177
- />
178
- {/if}
179
- {#each panelActions as action (action.id)}
180
- <Button
181
- variant={action.variant ?? 'outline'}
182
- size={action.size ?? 'sm'}
183
- onclick={action.onclick}
184
- >
185
- {#if action.icon}
186
- {@const IconComponent = action.icon}
187
- <IconComponent class="mr-2 h-4 w-4" />
188
- {/if}
189
- {action.label}
190
- </Button>
191
- {/each}
192
- </div>
193
- {#if !isMobile && showCalculateButton && schema.instanceSolve === false}
194
- <CalculateButton {hasPendingChanges} {hasNeverSolved} {isSolving} {oncalculate} />
195
- {/if}
259
+ {@render footer()}
196
260
  </div>
197
261
  {/if}
198
262
  </div>
199
263
  {/snippet}
200
264
 
201
- <div
265
+ <!-- Pinned under the scrolling fields, so the primary action never sits at the end of a long list. -->
266
+ {#snippet drawerFooter(isInert: boolean)}
267
+ <div class="drawer-footer" inert={isInert}>
268
+ {@render footer()}
269
+ </div>
270
+ {/snippet}
271
+
272
+ <main
202
273
  data-layout-root
203
274
  class="min-h-0 sm:flex-row sm:py-(--page-py) flex flex-1 flex-col overflow-hidden {leftCollapsed
204
275
  ? 'sm:pl-0'
@@ -230,12 +301,12 @@
230
301
  class:drawer-open={drawerOpen}
231
302
  class:drawer-closed={!drawerOpen}
232
303
  >
304
+ <!-- Named by its visible label; aria-expanded carries the open/closed state. -->
233
305
  <button
234
306
  class="drawer-handle-bar"
235
307
  onclick={() => (drawerOpen = !drawerOpen)}
236
308
  ontouchstart={onDrawerTouchStart}
237
309
  ontouchend={onDrawerTouchEnd}
238
- aria-label={drawerOpen ? t.collapsePanel : t.expandPanel}
239
310
  aria-expanded={drawerOpen}
240
311
  >
241
312
  <div class="drawer-pill"></div>
@@ -248,62 +319,47 @@
248
319
  />
249
320
  </button>
250
321
 
251
- <div class="drawer-content">
322
+ <!-- Inert while closed: the fields are clipped out of view, and tabbing into them
323
+ would scroll the drawer's insides around behind the handle. -->
324
+ <div class="drawer-content" inert={!drawerOpen}>
252
325
  <div class="px-3 pb-6">
253
326
  {#if hasLeftPanel}
254
- {@render panelContent(
255
- hasRightPanel ? 'left' : undefined,
256
- requestedLeftTabId,
257
- !hasRightPanel,
258
- false
259
- )}
327
+ {@render panelContent(hasRightPanel ? 'left' : undefined, requestedLeftTabId, false)}
260
328
  {/if}
261
329
  {#if hasRightPanel}
262
- <div class="my-4 gap-2 flex items-center">
330
+ <!-- Decorative: the section below names itself with a heading. -->
331
+ <div class="my-4 gap-2 flex items-center" aria-hidden="true">
263
332
  <div class="h-px flex-1 bg-border"></div>
264
333
  <span class="text-xs font-medium px-1 text-muted-foreground">
265
334
  {rightTabs[0]?.label ?? t.moreTab}
266
335
  </span>
267
336
  <div class="h-px flex-1 bg-border"></div>
268
337
  </div>
269
- {@render panelContent('right', requestedRightTabId, true, false)}
338
+ {@render panelContent('right', requestedRightTabId, false)}
270
339
  {/if}
271
340
  </div>
272
341
  </div>
273
342
 
274
- {#if schema.instanceSolve === false}
275
- <div class="drawer-footer">
276
- <CalculateButton {hasPendingChanges} {hasNeverSolved} {isSolving} {oncalculate} />
277
- </div>
278
- {/if}
343
+ {@render drawerFooter(!drawerOpen)}
279
344
  </div>
280
345
  {:else}
281
346
  <div class="flex flex-1 flex-col overflow-hidden">
282
347
  <div class="px-3 pt-3 flex-1 overflow-y-auto">
283
348
  {#if hasLeftPanel}
284
- {@render panelContent(
285
- hasRightPanel ? 'left' : undefined,
286
- requestedLeftTabId,
287
- !hasRightPanel,
288
- false
289
- )}
349
+ {@render panelContent(hasRightPanel ? 'left' : undefined, requestedLeftTabId, false)}
290
350
  {/if}
291
351
  {#if hasRightPanel}
292
- <div class="my-4 gap-2 flex items-center">
352
+ <div class="my-4 gap-2 flex items-center" aria-hidden="true">
293
353
  <div class="h-px flex-1 bg-border"></div>
294
354
  <span class="text-xs font-medium px-1 text-muted-foreground">
295
355
  {rightTabs[0]?.label ?? t.moreTab}
296
356
  </span>
297
357
  <div class="h-px flex-1 bg-border"></div>
298
358
  </div>
299
- {@render panelContent('right', requestedRightTabId, true, false)}
359
+ {@render panelContent('right', requestedRightTabId, false)}
300
360
  {/if}
301
361
  </div>
302
- {#if schema.instanceSolve === false}
303
- <div class="drawer-footer">
304
- <CalculateButton {hasPendingChanges} {hasNeverSolved} {isSolving} {oncalculate} />
305
- </div>
306
- {/if}
362
+ {@render drawerFooter(false)}
307
363
  </div>
308
364
  {/if}
309
365
  {:else if !hasViewer && !isTwoPanelMode && hasLeftPanel !== hasRightPanel}
@@ -312,9 +368,7 @@
312
368
  <div class="min-h-0 flex h-full w-4/5 flex-col">
313
369
  {@render panelContent(
314
370
  hasRightPanel ? 'right' : undefined,
315
- hasRightPanel ? requestedRightTabId : requestedLeftTabId,
316
- !hasRightPanel,
317
- !hasRightPanel
371
+ hasRightPanel ? requestedRightTabId : requestedLeftTabId
318
372
  )}
319
373
  </div>
320
374
  </div>
@@ -412,7 +466,7 @@
412
466
  ? 'hidden'
413
467
  : ''}"
414
468
  >
415
- {@render panelContent('right', requestedRightTabId, !hasLeftPanel, !hasLeftPanel)}
469
+ {@render panelContent('right', requestedRightTabId, !hasLeftPanel)}
416
470
  </div>
417
471
  </Resizable.Pane>
418
472
  {/if}
@@ -432,9 +486,20 @@
432
486
  {/if}
433
487
  </div>
434
488
  {/if}
435
- </div>
436
489
 
437
- <SolvingIndicator show={showSolvingIndicator} />
490
+ <SolvingIndicator show={showSolvingIndicator} />
491
+ </main>
492
+
493
+ <!-- The save, load and validation dialogs; the footer's menu opens them. -->
494
+ <ParameterPresetManager
495
+ bind:this={presets}
496
+ {schema}
497
+ currentValues={values}
498
+ onLoadValues={handleLoadValues}
499
+ {onSaveState}
500
+ {onListStates}
501
+ labels={presetLabels}
502
+ />
438
503
 
439
504
  <style>
440
505
  .fullscreen-layout {
@@ -480,8 +545,10 @@
480
545
  .drawer-closed {
481
546
  height: 60px;
482
547
  }
548
+ /* The pinned footer takes a fixed slice, so a short phone opens the drawer further to leave
549
+ room for the fields; a tall one keeps it at 60% of the screen. */
483
550
  .drawer-open {
484
- height: 60svh;
551
+ height: max(60svh, min(30rem, 100svh - 10rem));
485
552
  }
486
553
 
487
554
  .drawer-handle-bar {
@@ -524,6 +591,25 @@
524
591
  .drawer-footer {
525
592
  flex-shrink: 0;
526
593
  padding: 0rem 1rem;
527
- border-top: 1px solid var(--border);
594
+ }
595
+
596
+ /* Touch gets 44px targets on the panel's controls; a fine pointer keeps the denser sizes. */
597
+ @media (pointer: coarse) {
598
+ .panel-content-wrapper
599
+ :global(
600
+ :is(
601
+ [data-slot='button'],
602
+ [data-slot='input'],
603
+ [data-slot='select-trigger'],
604
+ [data-slot='input-group']
605
+ )
606
+ ),
607
+ .drawer-footer :global([data-slot='button']) {
608
+ min-height: 2.75rem;
609
+ }
610
+ .panel-content-wrapper :global([data-slot='input-group'] > [data-slot='input-group-control']) {
611
+ align-self: stretch;
612
+ height: auto;
613
+ }
528
614
  }
529
615
  </style>
@@ -7,7 +7,12 @@
7
7
  import type { PresetLabels } from '../../types/presetLabels';
8
8
  import { createSolvingIndicator } from '../../compute/solving.svelte';
9
9
  import { createRequestResponseDriver } from '@selvajs/solve/client';
10
- import type { RetainedSolveResult, SolveEventSource, SolveSession } from '@selvajs/solve/client';
10
+ import type {
11
+ RetainedSolveResult,
12
+ SolveEventSource,
13
+ SolveSession,
14
+ SolveSettlement
15
+ } from '@selvajs/solve/client';
11
16
  import SolveMessages from './SolveMessages.svelte';
12
17
  import { meshPolicy } from '@selvajs/visualization/parse';
13
18
  import type { ThreeViewer } from '@selvajs/visualization/render';
@@ -19,8 +24,8 @@
19
24
  import { type ViewerConfig } from '../viewer/Viewer.svelte';
20
25
  import StateDisplay from '../primitives/StateDisplay.svelte';
21
26
  import { setClientSlot, type ClientSlot } from '../../contexts/clientSlotContext.svelte';
22
- import type { Locale } from '../../i18n/messages';
23
- import { setLocaleContext, getLocaleContext } from '../../i18n/localeContext.svelte';
27
+ import type { Locale, ViewerMessages } from '../../i18n/messages';
28
+ import { layerLocaleContext } from '../../i18n/localeContext.svelte';
24
29
 
25
30
  import type { Snippet } from 'svelte';
26
31
 
@@ -34,8 +39,11 @@
34
39
  isEmbedded?: boolean;
35
40
  primaryColor?: string;
36
41
  showModeToggle?: boolean;
42
+ /** Host actions, listed in the panel footer's ⋯ menu after saving and loading inputs. */
37
43
  panelActions?: ActionButton[];
44
+ /** Lists "Save inputs…" in the footer menu. */
38
45
  showSaveButton?: boolean;
46
+ /** Lists "Load inputs…" in the footer menu. */
39
47
  showLoadButton?: boolean;
40
48
  /** When set, persist saved states via this callback instead of downloading a .slvp file. */
41
49
  onSaveState?: (state: ParameterPreset) => void | Promise<void>;
@@ -74,6 +82,12 @@
74
82
  */
75
83
  getSession: () => SolveSession;
76
84
  }) => void;
85
+ /**
86
+ * Once per settled solve: applied, discarded, blocked, aborted or failed. A result held
87
+ * for review counts when the user accepts or rejects it, and one a queued solve replaces
88
+ * never does. Auto-solves from input changes count too.
89
+ */
90
+ onSolved?: (settlement: SolveSettlement) => void;
77
91
  /** Hands the live three.js viewer to the host once it mounts. See `Viewer.svelte` for the contract. */
78
92
  onViewerReady?: (viewer: ThreeViewer) => void | (() => void);
79
93
  /** Viewer chrome and defaults. `backgroundColor` and `showSceneManager` are set by the layout. */
@@ -92,6 +106,14 @@
92
106
  // Language for the app's own chrome, English when unset. Does not translate
93
107
  // schema-authored labels or Grasshopper-sourced names/metadata.
94
108
  lang?: Locale;
109
+ /**
110
+ * Replaces individual strings of the app's own chrome, layered over the `lang` catalog
111
+ * (`VIEWER_MESSAGES`): reword a label, or supply every string for a language with no
112
+ * built-in catalog. Reaches every component inside, the viewer included. A `{name}`
113
+ * placeholder in the default is filled the same way in an override. The preset manager's
114
+ * strings come from `presetLabels` instead.
115
+ */
116
+ messages?: Partial<ViewerMessages>;
95
117
  }
96
118
 
97
119
  let {
@@ -123,22 +145,26 @@
123
145
  brandName,
124
146
  header,
125
147
  onReady,
148
+ onSolved,
126
149
  onViewerReady,
127
150
  viewerConfig = {},
128
151
  externalScopeKey,
129
152
  clientSlot,
130
- lang
153
+ lang,
154
+ messages
131
155
  }: Props = $props();
132
156
 
133
157
  // Reaches InputControl deep in the tree.
134
158
  // svelte-ignore state_referenced_locally
135
159
  setClientSlot(clientSlot);
136
160
 
137
- // Resolution: explicit `lang` → host-provided locale → English. Passed as a getter so
138
- // switching `lang` updates the chrome live.
139
- const hostLocale = getLocaleContext();
140
- setLocaleContext(() => lang ?? hostLocale.locale);
141
- const t = $derived(getLocaleContext().messages);
161
+ // Resolution: explicit `lang` → host-provided locale → English, with `messages` on top.
162
+ // Getters, so switching either updates the chrome live.
163
+ const locale = layerLocaleContext(
164
+ () => lang,
165
+ () => messages
166
+ );
167
+ const t = $derived(locale.messages);
142
168
 
143
169
  const resolvedScopeKey = $derived(externalScopeKey || definitionKey || schema?.id || '');
144
170
 
@@ -168,6 +194,9 @@
168
194
 
169
195
  const solvingIndicator = createSolvingIndicator(() => session.isSolving);
170
196
 
197
+ // Read at call time, so a host swapping the callback needs no resubscribe.
198
+ $effect(() => session.onSettled((settlement) => onSolved?.(settlement)));
199
+
171
200
  $effect(() => {
172
201
  onReady?.({
173
202
  loadValues: (incoming) => session.loadValues(incoming),
@@ -272,6 +301,7 @@
272
301
  {presetLabels}
273
302
  onValueChange={handleValueChange}
274
303
  oncalculate={handleCalculate}
304
+ ensureSolved={() => session.ensureSolved()}
275
305
  onLoadValues={() => session.loadValues({})}
276
306
  />
277
307
  {/key}
@@ -286,15 +316,4 @@
286
316
  onabort={() => session.abort()}
287
317
  onconfirm={() => session.acknowledge()}
288
318
  ondiscard={() => session.discard()}
289
- labels={{
290
- solving: t.solveSolving,
291
- blockedTitle: t.solveBlockedTitle,
292
- messagesTitle: t.solveMessagesTitle,
293
- blockedDescription: t.solveBlocked,
294
- messagesDescription: t.solveMessagesDescription,
295
- confirm: t.solveMessagesConfirm,
296
- discard: t.solveMessagesDiscard,
297
- abort: t.solveMessagesAbort,
298
- dismiss: t.solveMessagesDismiss
299
- }}
300
319
  />
@@ -10,6 +10,8 @@
10
10
  import type { SolveDiagnostic } from '@selvajs/solve/shared';
11
11
  import * as Dialog from '$lib/components/primitives/dialog';
12
12
  import IssueList, { type Issue } from '$lib/components/primitives/IssueList.svelte';
13
+ import { getLocaleContext } from '$lib/i18n/localeContext.svelte';
14
+ import { formatCount } from '$lib/i18n/messages';
13
15
 
14
16
  interface Props {
15
17
  /** The last solve's messages. Preferred over `errors`/`warnings` when both are given. */
@@ -23,6 +25,9 @@
23
25
 
24
26
  let { diagnostics, errors = [], warnings = [], trigger }: Props = $props();
25
27
 
28
+ const locale = getLocaleContext();
29
+ const t = $derived(locale.messages);
30
+
26
31
  let open = $state(false);
27
32
 
28
33
  const rows = $derived<SolveDiagnostic[]>(
@@ -41,26 +46,11 @@
41
46
  count: number;
42
47
  }
43
48
 
44
- const LEVELS = [
45
- {
46
- level: 'error',
47
- severity: 'error',
48
- one: 'error',
49
- many: 'errors'
50
- },
51
- {
52
- level: 'warning',
53
- severity: 'warning',
54
- one: 'warning',
55
- many: 'warnings'
56
- },
57
- {
58
- level: 'remark',
59
- severity: 'note',
60
- one: 'note',
61
- many: 'notes'
62
- }
63
- ] as const;
49
+ const levels = $derived([
50
+ { level: 'error', severity: 'error', one: t.errorCountOne, other: t.errorCountOther },
51
+ { level: 'warning', severity: 'warning', one: t.warningCountOne, other: t.warningCountOther },
52
+ { level: 'remark', severity: 'note', one: t.noteCountOne, other: t.noteCountOther }
53
+ ] as const);
64
54
 
65
55
  /**
66
56
  * One section per level that has messages, most severe first; within a level, the order the
@@ -68,23 +58,25 @@
68
58
  * branches raises the same warning 200 times, and an unstacked list buries every other message.
69
59
  */
70
60
  const sections = $derived(
71
- LEVELS.map((meta) => {
72
- // A null-prototype object rather than a Map: a local built fresh on each derive, never
73
- // reactive state, and the lint rule that steers Map → SvelteMap cannot tell them apart.
74
- const byKey: Record<string, Item> = Object.create(null);
75
- const items: Item[] = [];
76
- for (const d of rows) {
77
- if (d.level !== meta.level) continue;
78
- const key = `${d.message} ${d.source ?? ''}`;
79
- if (byKey[key]) {
80
- byKey[key].count++;
81
- } else {
82
- byKey[key] = { level: d.level, message: d.message, source: d.source, count: 1 };
83
- items.push(byKey[key]);
61
+ levels
62
+ .map((meta) => {
63
+ // A null-prototype object rather than a Map: a local built fresh on each derive, never
64
+ // reactive state, and the lint rule that steers Map → SvelteMap cannot tell them apart.
65
+ const byKey: Record<string, Item> = Object.create(null);
66
+ const items: Item[] = [];
67
+ for (const d of rows) {
68
+ if (d.level !== meta.level) continue;
69
+ const key = `${d.message} ${d.source ?? ''}`;
70
+ if (byKey[key]) {
71
+ byKey[key].count++;
72
+ } else {
73
+ byKey[key] = { level: d.level, message: d.message, source: d.source, count: 1 };
74
+ items.push(byKey[key]);
75
+ }
84
76
  }
85
- }
86
- return { ...meta, items };
87
- }).filter((section) => section.items.length > 0)
77
+ return { ...meta, items };
78
+ })
79
+ .filter((section) => section.items.length > 0)
88
80
  );
89
81
 
90
82
  const issues = $derived<Issue[]>(
@@ -106,13 +98,11 @@
106
98
 
107
99
  <Dialog.Content class="max-w-lg">
108
100
  <Dialog.Header>
109
- <Dialog.Title>Messages</Dialog.Title>
101
+ <Dialog.Title>{t.messagesTitle}</Dialog.Title>
110
102
  <Dialog.Description>
111
103
  {#each sections as section, i (section.level)}
112
104
  {#if i > 0}<span class="mx-1.5">·</span>{/if}
113
- <span
114
- >{section.items.length} {section.items.length === 1 ? section.one : section.many}</span
115
- >
105
+ <span>{formatCount(section.items.length, section.one, section.other)}</span>
116
106
  {/each}
117
107
  </Dialog.Description>
118
108
  </Dialog.Header>